From b5c04c89eea97d2d3547371dfc29f2973dde3bc7 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 17:54:20 +0530 Subject: [PATCH 01/36] Add a provider type for any Chat Completions server --- crates/providers/src/custom.rs | 416 +++++++++++++++++++++++++++++++++ crates/providers/src/lib.rs | 2 + 2 files changed, 418 insertions(+) create mode 100644 crates/providers/src/custom.rs diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs new file mode 100644 index 0000000..7425622 --- /dev/null +++ b/crates/providers/src/custom.rs @@ -0,0 +1,416 @@ +//! A provider the user adds at runtime: any server that speaks Chat +//! Completions, known by a name and a base URL. + +use crate::{ + ApiId, AuthError, AuthMethod, AuthResolver, AuthResult, Credential, CredentialStore, ModelInfo, + Provider, ProviderError, ProviderId, ServerToolInfo, +}; +use async_trait::async_trait; +use llm::{ChatCompletionsApi, Credential as RequestCredential, HttpClient, LlmApi}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +/// Where servers report a model's context window. No field is standard, so +/// the first one holding a positive integer wins. +const CONTEXT_FIELDS: [&str; 4] = [ + "context_length", + "context_window", + "max_context_length", + "max_model_len", +]; + +pub struct CustomProvider { + id: ProviderId, + base_url: String, + models: RwLock>, + apis: HashMap>, + auth: Arc, +} + +impl CustomProvider { + /// `models` seeds the catalog so a provider checked a moment ago needs no + /// second `/models` call; an empty list is filled on the next fetch. + pub fn new( + id: ProviderId, + base_url: impl Into, + credentials: Arc, + models: Vec, + ) -> Self { + let base_url = base_url.into(); + let api: Arc = Arc::new(ChatCompletionsApi::new( + base_url.clone(), + Arc::new(HttpClient::new()), + )); + Self { + auth: Arc::new(StoredKeyAuth { + provider: id.clone(), + credentials, + }), + apis: HashMap::from([(ApiId::ChatCompletions, api)]), + models: RwLock::new(models), + base_url, + id, + } + } +} + +#[async_trait] +impl Provider for CustomProvider { + fn id(&self) -> ProviderId { + self.id.clone() + } + + fn models(&self) -> Vec { + self.models + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + fn server_tools(&self) -> &[ServerToolInfo] { + &[] + } + + fn auth_methods(&self) -> Vec { + vec![AuthMethod::ApiKey] + } + + fn apis(&self) -> &HashMap> { + &self.apis + } + + fn auth(&self) -> Arc { + self.auth.clone() + } + + async fn validate_auth(&self, auth_result: &AuthResult) -> Result<(), AuthError> { + match auth_result { + AuthResult::ApiKey(key) => fetch_catalog(&self.id, &self.base_url, Some(key)) + .await + .map(drop) + .map_err(|error| AuthError::Validation(error.to_string())), + } + } + + async fn fetch_models(&self) -> Result<(), ProviderError> { + let credential = self + .auth + .resolve() + .await + .map_err(|error| ProviderError::Fetch(format!("authentication failed: {error}")))?; + let key = match credential { + RequestCredential::ApiKey(key) => Some(key), + _ => None, + }; + let models = fetch_catalog(&self.id, &self.base_url, key.as_deref()).await?; + *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; + Ok(()) + } +} + +/// Reads the provider's key from the credential store. Without one, requests +/// carry no `Authorization` header, which keyless local servers expect. +struct StoredKeyAuth { + provider: ProviderId, + credentials: Arc, +} + +#[async_trait] +impl AuthResolver for StoredKeyAuth { + async fn resolve(&self) -> Result { + Ok(match self.credentials.read(&self.provider).await? { + Some(Credential::ApiKey { key }) if !key.is_empty() => RequestCredential::ApiKey(key), + _ => RequestCredential::None, + }) + } +} + +/// `GET {base_url}/models`. Chat Completions has no health endpoint, so this +/// is how a URL and key are checked before a provider is saved. +pub async fn fetch_catalog( + provider: &ProviderId, + base_url: &str, + api_key: Option<&str>, +) -> Result, ProviderError> { + let mut request = reqwest::Client::new() + .get(format!("{}/models", base_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_catalog(provider, &payload) +} + +fn parse_catalog(provider: &ProviderId, payload: &str) -> Result, ProviderError> { + #[derive(Deserialize)] + struct Listing { + data: Vec, + } + + let listing: Listing = serde_json::from_str(payload) + .map_err(|_| ProviderError::Fetch("not an OpenAI-style model list".into()))?; + let mut models: Vec = listing + .data + .iter() + .filter_map(|entry| { + let id = entry.get("id")?.as_str()?.trim(); + (!id.is_empty()).then(|| ModelInfo { + provider: provider.clone(), + id: id.to_owned(), + name: id.to_owned(), + pricing: None, + context_length: context_length(entry), + }) + }) + .collect(); + models.sort_by(|a, b| a.id.cmp(&b.id)); + models.dedup_by(|a, b| a.id == b.id); + if models.is_empty() { + return Err(ProviderError::Fetch("server listed no models".into())); + } + Ok(models) +} + +/// A zero window would paint the context badge full from the first token, so +/// it counts as missing, like a string or a float. +fn context_length(entry: &serde_json::Value) -> Option { + CONTEXT_FIELDS + .iter() + .find_map(|field| entry.get(field)?.as_u64().filter(|&tokens| tokens > 0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::InMemoryCredentialStore; + + const PROVIDER: &str = "grok"; + + fn id() -> ProviderId { + ProviderId::new(PROVIDER) + } + + // 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) + } + + const TWO_MODELS: &str = "HTTP/1.1 200 OK\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m2\"},{\"id\":\"m1\",\"context_window\":8192}]}"; + + #[test] + fn parse_catalog_reads_each_known_context_field() { + let models = parse_catalog( + &id(), + r#"{"data":[ + {"id":"a","context_length":128000}, + {"id":"b","context_window":131072}, + {"id":"c","max_context_length":32768}, + {"id":"d","max_model_len":8192}, + {"id":"e"}]}"#, + ) + .unwrap(); + let contexts: Vec<_> = models + .iter() + .map(|m| (m.id.as_str(), m.context_length)) + .collect(); + assert_eq!( + contexts, + [ + ("a", Some(128000)), + ("b", Some(131072)), + ("c", Some(32768)), + ("d", Some(8192)), + ("e", None) + ] + ); + assert!(models.iter().all(|m| m.provider == id() && m.name == m.id)); + } + + #[test] + fn context_length_takes_the_first_field_present() { + let models = parse_catalog( + &id(), + r#"{"data":[{"id":"a","context_length":1000,"context_window":2000}]}"#, + ) + .unwrap(); + assert_eq!(models[0].context_length, Some(1000)); + } + + #[test] + fn context_length_ignores_zero_strings_and_floats() { + let models = parse_catalog( + &id(), + r#"{"data":[ + {"id":"a","context_length":0,"context_window":"4096","max_context_length":4096.5,"max_model_len":2048}, + {"id":"b","context_length":"128k"}]}"#, + ) + .unwrap(); + assert_eq!(models[0].context_length, Some(2048)); + assert_eq!(models[1].context_length, None); + } + + #[test] + fn parse_catalog_sorts_dedupes_and_skips_blank_ids() { + let models = parse_catalog( + &id(), + r#"{"data":[{"id":"b"},{"id":"a","owned_by":"x"},{"id":"b"},{"id":" "},{"object":"model"}]}"#, + ) + .unwrap(); + let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["a", "b"]); + } + + #[test] + fn parse_catalog_rejects_empty_and_other_shapes() { + assert!(parse_catalog(&id(), r#"{"data":[]}"#).is_err()); + assert!(parse_catalog(&id(), r#"{"models":[{"name":"models/gemini"}]}"#).is_err()); + assert!(parse_catalog(&id(), "404").is_err()); + } + + #[tokio::test] + async fn fetch_catalog_sends_key_to_models_path() { + let (url, server) = serve_once(TWO_MODELS).await; + let models = fetch_catalog(&id(), &url, Some("sk-test")).await.unwrap(); + assert_eq!(models.len(), 2); + 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 fetch_catalog_sends_no_auth_without_key() { + let (url, server) = serve_once(TWO_MODELS).await; + fetch_catalog(&id(), &url, None).await.unwrap(); + assert!( + !server + .await + .unwrap() + .to_lowercase() + .contains("authorization") + ); + } + + #[tokio::test] + async fn fetch_catalog_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 = fetch_catalog(&id(), &url, Some("bad")).await.unwrap_err(); + assert!(error.to_string().contains("401"), "{error}"); + } + + #[tokio::test] + async fn fetch_catalog_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 = fetch_catalog(&id(), &format!("http://{address}/v1"), None) + .await + .unwrap_err(); + assert!(error.to_string().contains("could not connect"), "{error}"); + } + + #[tokio::test] + async fn fetch_catalog_reports_invalid_url() { + let error = fetch_catalog(&id(), "not a url", None).await.unwrap_err(); + assert!(error.to_string().contains("invalid URL"), "{error}"); + } + + #[tokio::test] + async fn fetch_models_uses_the_stored_key() { + let (url, server) = serve_once(TWO_MODELS).await; + let credentials = Arc::new(InMemoryCredentialStore::new()); + credentials + .put( + &id(), + Credential::ApiKey { + key: "sk-stored".into(), + }, + ) + .await + .unwrap(); + let provider = CustomProvider::new(id(), url, credentials, Vec::new()); + provider.fetch_models().await.unwrap(); + assert_eq!(provider.models().len(), 2); + assert!( + server + .await + .unwrap() + .to_lowercase() + .contains("authorization: bearer sk-stored") + ); + } + + #[tokio::test] + async fn provider_without_a_key_sends_no_credential() { + let provider = CustomProvider::new( + id(), + "http://localhost:11434/v1", + Arc::new(InMemoryCredentialStore::new()), + Vec::new(), + ); + let credential = provider.auth().resolve().await.unwrap(); + assert!(matches!(credential, RequestCredential::None)); + } + + #[tokio::test] + async fn validate_auth_checks_the_key_against_models() { + let (url, _server) = + serve_once("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}").await; + let provider = CustomProvider::new( + id(), + url, + Arc::new(InMemoryCredentialStore::new()), + Vec::new(), + ); + let result = provider + .validate_auth(&AuthResult::ApiKey("bad".into())) + .await; + assert!(matches!(result, Err(AuthError::Validation(_)))); + } +} diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index 4e4cfb5..2185e0a 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -1,6 +1,7 @@ mod auth; mod catalog; mod credentials; +mod custom; mod deepseek; mod google; mod local; @@ -10,6 +11,7 @@ mod provider; mod store; mod zai; +pub use custom::{CustomProvider, fetch_catalog}; pub use deepseek::{DeepSeekBuilder, DeepSeekProvider}; pub use auth::{ From 7d3f994ee4b68f559394ae9353f3d917a8dd57f8 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 17:54:58 +0530 Subject: [PATCH 02/36] Let providers be added and removed while Alan runs --- crates/providers/src/provider.rs | 76 +++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index d055102..5ae4e0e 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -6,7 +6,7 @@ use crate::model::ModelOptions; use async_trait::async_trait; use llm::LlmApi; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use thiserror::Error; #[derive(Debug, Error)] @@ -51,14 +51,14 @@ pub trait Provider: Send + Sync { } pub struct ProviderRegistry { - providers: Vec>, + providers: RwLock>>, local_provider: Option>, } impl ProviderRegistry { pub fn new(providers: impl IntoIterator>) -> Self { Self { - providers: providers.into_iter().collect(), + providers: RwLock::new(providers.into_iter().collect()), local_provider: None, } } @@ -68,17 +68,45 @@ impl ProviderRegistry { local_provider: Arc, ) -> Self { Self { - providers: providers.into_iter().collect(), + providers: RwLock::new(providers.into_iter().collect()), local_provider: Some(local_provider), } } - pub fn providers(&self) -> &[Arc] { - &self.providers + /// A snapshot: `/provider` can change the set while Alan runs. + pub fn providers(&self) -> Vec> { + self.providers + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() } pub fn get(&self, id: &ProviderId) -> Option> { - self.providers.iter().find(|p| p.id() == *id).cloned() + self.providers + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .find(|p| p.id() == *id) + .cloned() + } + + /// Register `provider`, replacing one already registered under its id. + pub fn insert(&self, provider: Arc) { + let mut providers = self.providers.write().unwrap_or_else(|e| e.into_inner()); + match providers + .iter_mut() + .find(|existing| existing.id() == provider.id()) + { + Some(existing) => *existing = provider, + None => providers.push(provider), + } + } + + pub fn remove(&self, id: &ProviderId) -> bool { + let mut providers = self.providers.write().unwrap_or_else(|e| e.into_inner()); + let before = providers.len(); + providers.retain(|provider| provider.id() != *id); + providers.len() < before } /// Return an `Arc` clone of the local provider, if registered. @@ -111,3 +139,37 @@ pub fn bind_model( options, )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CustomProvider, InMemoryCredentialStore}; + + fn provider(name: &str, model: &str) -> Arc { + Arc::new(CustomProvider::new( + ProviderId::new(name), + "http://localhost:11434/v1", + Arc::new(InMemoryCredentialStore::new()), + vec![ModelInfo::new(model, ProviderId::new(name))], + )) + } + + #[test] + fn insert_adds_new_ids_and_replaces_existing_ones() { + let registry = ProviderRegistry::new([provider("a", "old")]); + registry.insert(provider("a", "new")); + registry.insert(provider("b", "m")); + + assert_eq!(registry.providers().len(), 2); + let a = registry.get(&ProviderId::new("a")).unwrap(); + assert_eq!(a.models()[0].id, "new"); + } + + #[test] + fn remove_reports_whether_a_provider_went() { + let registry = ProviderRegistry::new([provider("a", "m")]); + assert!(registry.remove(&ProviderId::new("a"))); + assert!(!registry.remove(&ProviderId::new("a"))); + assert!(registry.get(&ProviderId::new("a")).is_none()); + } +} From 29e4d01af258aada9924c5403a1ca69a9a3eea8e Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 17:56:43 +0530 Subject: [PATCH 03/36] Save custom providers and load them at startup --- crates/alan/src/core/custom_providers.rs | 528 +++++++++++++++++++++++ crates/alan/src/core/mod.rs | 1 + crates/alan/src/main.rs | 24 +- 3 files changed, 545 insertions(+), 8 deletions(-) create mode 100644 crates/alan/src/core/custom_providers.rs diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs new file mode 100644 index 0000000..0b02901 --- /dev/null +++ b/crates/alan/src/core/custom_providers.rs @@ -0,0 +1,528 @@ +//! Providers added with `/provider add`: any server that speaks Chat +//! Completions. Each is a name and a base URL in `providers.json`. Its key, +//! if any, lives in `auth.json` under the same name so `/login` can change it. +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "add, edit and remove are wired to /provider in the next commit" + ) +)] + +use anyhow::{Context, Result}; +use providers::{ + Credential, CredentialStore, CustomProvider, ModelInfo, ProviderId, ProviderRegistry, + fetch_catalog, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::core::paths; +use crate::core::store::JsonStore; + +const PROVIDERS_KEY: &str = "providers"; + +/// Built-in provider ids, plus `google`, which the providers crate has but +/// Alan does not register yet. A custom provider may not take one: its key +/// would overwrite the built-in provider's in `auth.json`. +const RESERVED_NAMES: [&str; 4] = ["openrouter", "deepseek", "zai", "google"]; + +/// A custom provider's name, and its id in `/models`, `auth.json`, +/// `settings.json` and `ALAN_PROVIDER`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ProviderName(String); + +impl ProviderName { + pub fn parse(name: &str) -> Result { + let name = name.trim(); + anyhow::ensure!(!name.is_empty(), "name is required"); + anyhow::ensure!( + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "name may only contain lowercase letters, digits and -" + ); + anyhow::ensure!( + !RESERVED_NAMES.contains(&name), + "{name} is a built-in provider" + ); + Ok(Self(name.to_owned())) + } + + pub fn id(&self) -> ProviderId { + ProviderId::new(&self.0) + } +} + +impl TryFrom for ProviderName { + type Error = anyhow::Error; + + fn try_from(name: String) -> Result { + Self::parse(&name) + } +} + +impl From for String { + fn from(name: ProviderName) -> Self { + name.0 + } +} + +impl fmt::Display for ProviderName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomProviderEntry { + pub name: ProviderName, + pub url: String, +} + +/// `providers.json`: the saved custom providers, in the order they were added. +pub struct CustomProviderStore { + store: JsonStore, +} + +impl CustomProviderStore { + pub fn new(path: impl Into) -> Self { + Self { + store: JsonStore::new(path), + } + } + + pub async fn load(&self) -> Result> { + decode(self.store.get(PROVIDERS_KEY).await?) + } + + /// Save `entry`, replacing a saved provider with the same name. + pub async fn put(&self, entry: CustomProviderEntry) -> Result<()> { + self.store + .update(PROVIDERS_KEY, move |value| { + let mut entries = decode(value)?; + match entries.iter_mut().find(|saved| saved.name == entry.name) { + Some(saved) => *saved = entry, + None => entries.push(entry), + } + Ok((Some(serde_json::to_value(entries)?), ())) + }) + .await + } + + pub async fn remove(&self, name: &ProviderName) -> Result { + self.store + .update(PROVIDERS_KEY, |value| { + let mut entries = decode(value)?; + let before = entries.len(); + entries.retain(|saved| saved.name != *name); + let removed = entries.len() < before; + let replacement = if entries.is_empty() { + None + } else { + Some(serde_json::to_value(entries)?) + }; + Ok((replacement, removed)) + }) + .await + } +} + +fn decode(value: Option) -> Result> { + Ok(value + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default()) +} + +/// Default location of providers.json inside the Alan data directory. +pub fn default_providers_path() -> Result { + paths::data_file("providers.json") +} + +/// Adds, edits and removes custom providers, keeping `providers.json`, +/// `auth.json` and the live registry in step. +pub struct CustomProviders { + store: CustomProviderStore, + registry: Arc, + credentials: Arc, +} + +impl CustomProviders { + pub fn new( + store: CustomProviderStore, + registry: Arc, + credentials: Arc, + ) -> Self { + Self { + store, + registry, + credentials, + } + } + + /// Register every saved provider. Their models are fetched the next time + /// `/models` opens. + pub async fn load(&self) -> Result<()> { + for entry in self.store.load().await? { + self.register(&entry, Vec::new()); + } + Ok(()) + } + + pub async fn entries(&self) -> Result> { + self.store.load().await + } + + /// The saved key, to prefill the edit form. + pub async fn key(&self, name: &ProviderName) -> Result> { + Ok(self + .credentials + .read(&name.id()) + .await? + .map(|Credential::ApiKey { key }| key)) + } + + /// Check `url` with a `/models` call, then save the provider and its key. + /// Returns how many models the server lists. Nothing is saved when the + /// check fails. + pub async fn add(&self, name: ProviderName, url: &str, key: Option) -> Result { + anyhow::ensure!( + self.registry.get(&name.id()).is_none(), + "a provider named {name} already exists" + ); + self.save(name, url, key).await + } + + /// [`add`](Self::add) for a saved provider. No key removes the saved one. + pub async fn update( + &self, + name: ProviderName, + url: &str, + key: Option, + ) -> Result { + self.save(name, url, key).await + } + + /// Refuses the provider of the running model: its next request would go + /// out with the key this deletes. + pub async fn remove(&self, name: &ProviderName, active: &ProviderId) -> Result<()> { + anyhow::ensure!( + name.id() != *active, + "{name} is in use; pick another model with /models first" + ); + self.store.remove(name).await?; + self.credentials.delete(&name.id()).await?; + self.registry.remove(&name.id()); + Ok(()) + } + + async fn save(&self, name: ProviderName, url: &str, key: Option) -> Result { + let url = url.trim().trim_end_matches('/').to_owned(); + let models = fetch_catalog(&name.id(), &url, key.as_deref()) + .await + .with_context(|| format!("checking {url}/models"))?; + let count = models.len(); + match key { + Some(key) => { + self.credentials + .put(&name.id(), Credential::ApiKey { key }) + .await? + } + None => self.credentials.delete(&name.id()).await?, + } + let entry = CustomProviderEntry { name, url }; + self.store.put(entry.clone()).await?; + self.register(&entry, models); + Ok(count) + } + + fn register(&self, entry: &CustomProviderEntry, models: Vec) { + self.registry.insert(Arc::new(CustomProvider::new( + entry.name.id(), + entry.url.clone(), + Arc::clone(&self.credentials), + models, + ))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::store::JsonStore; + use providers::InMemoryCredentialStore; + use serde_json::json; + use std::env::temp_dir; + + const OK_TWO_MODELS: &str = + "HTTP/1.1 200 OK\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"},{\"id\":\"m2\"}]}"; + + fn temp_path(name: &str) -> PathBuf { + let path = temp_dir().join(format!( + "alan-custom-providers-test-{}-{name}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + path + } + + fn name(value: &str) -> ProviderName { + ProviderName::parse(value).unwrap() + } + + fn service( + file: &str, + ) -> ( + CustomProviders, + Arc, + Arc, + ) { + let registry = Arc::new(ProviderRegistry::new([])); + let credentials = Arc::new(InMemoryCredentialStore::new()); + let providers = CustomProviders::new( + CustomProviderStore::new(temp_path(file)), + Arc::clone(®istry), + credentials.clone(), + ); + (providers, registry, credentials) + } + + // one-shot server: replies `response`, hands back the raw request. + async fn serve_once(response: &'static str) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let address = listener.local_addr().unwrap(); + 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(); + }); + format!("http://{address}/v1/") + } + + #[test] + fn provider_name_accepts_lowercase_digits_and_dashes() { + assert_eq!(name(" my-groq2 ").to_string(), "my-groq2"); + } + + #[test] + fn provider_name_rejects_bad_and_reserved_names() { + for bad in [ + "", + "Grok", + "my groq", + "groq!", + "openrouter", + "deepseek", + "zai", + "google", + ] { + assert!(ProviderName::parse(bad).is_err(), "{bad:?} was accepted"); + } + } + + #[tokio::test] + async fn store_put_replaces_by_name_and_keeps_order() { + let store = CustomProviderStore::new(temp_path("put")); + for (n, url) in [ + ("a", "http://one"), + ("b", "http://two"), + ("a", "http://three"), + ] { + store + .put(CustomProviderEntry { + name: name(n), + url: url.into(), + }) + .await + .unwrap(); + } + let urls: Vec<_> = store + .load() + .await + .unwrap() + .into_iter() + .map(|e| e.url) + .collect(); + assert_eq!(urls, ["http://three", "http://two"]); + } + + #[tokio::test] + async fn store_remove_reports_whether_an_entry_went() { + let store = CustomProviderStore::new(temp_path("remove")); + store + .put(CustomProviderEntry { + name: name("a"), + url: "http://one".into(), + }) + .await + .unwrap(); + assert!(store.remove(&name("a")).await.unwrap()); + assert!(!store.remove(&name("a")).await.unwrap()); + assert!(store.load().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn store_load_rejects_an_invalid_name_in_the_file() { + let path = temp_path("corrupt"); + JsonStore::new(path.clone()) + .set( + PROVIDERS_KEY, + json!([{"name": "Bad Name", "url": "http://x"}]), + ) + .await + .unwrap(); + assert!(CustomProviderStore::new(path).load().await.is_err()); + } + + #[tokio::test] + async fn add_checks_then_saves_entry_key_and_provider() { + let (providers, registry, credentials) = service("add"); + let url = serve_once(OK_TWO_MODELS).await; + + let count = providers + .add(name("grok"), &url, Some("sk".into())) + .await + .unwrap(); + + assert_eq!(count, 2); + let entries = providers.entries().await.unwrap(); + assert_eq!(entries[0].url, url.trim_end_matches('/')); + assert_eq!( + providers.key(&name("grok")).await.unwrap().as_deref(), + Some("sk") + ); + assert!( + credentials + .read(&name("grok").id()) + .await + .unwrap() + .is_some() + ); + let provider = registry.get(&name("grok").id()).unwrap(); + assert_eq!(provider.models().len(), 2); + } + + #[tokio::test] + async fn add_saves_nothing_when_the_check_fails() { + let (providers, registry, credentials) = service("add-fails"); + let url = serve_once("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}").await; + + let error = providers + .add(name("grok"), &url, Some("bad".into())) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("401"), "{error:#}"); + assert!(providers.entries().await.unwrap().is_empty()); + assert!( + credentials + .read(&name("grok").id()) + .await + .unwrap() + .is_none() + ); + assert!(registry.get(&name("grok").id()).is_none()); + } + + #[tokio::test] + async fn add_refuses_a_taken_name_before_any_request() { + let (providers, registry, credentials) = service("taken"); + registry.insert(Arc::new(CustomProvider::new( + name("taken").id(), + "http://localhost:1/v1", + credentials, + Vec::new(), + ))); + + // Port 1 is closed: reaching the network would say "could not connect". + let error = providers + .add(name("taken"), "http://127.0.0.1:1/v1", None) + .await + .unwrap_err(); + + assert!(error.to_string().contains("already exists"), "{error:#}"); + } + + #[tokio::test] + async fn update_without_a_key_deletes_the_saved_one() { + let (providers, _registry, credentials) = service("update"); + credentials + .put(&name("grok").id(), Credential::ApiKey { key: "old".into() }) + .await + .unwrap(); + let url = serve_once(OK_TWO_MODELS).await; + + providers.update(name("grok"), &url, None).await.unwrap(); + + assert!(providers.key(&name("grok")).await.unwrap().is_none()); + } + + #[tokio::test] + async fn remove_deletes_entry_key_and_provider() { + let (providers, registry, _credentials) = service("remove-all"); + let url = serve_once(OK_TWO_MODELS).await; + providers + .add(name("grok"), &url, Some("sk".into())) + .await + .unwrap(); + + providers + .remove(&name("grok"), &ProviderId::new("openrouter")) + .await + .unwrap(); + + assert!(providers.entries().await.unwrap().is_empty()); + assert!(providers.key(&name("grok")).await.unwrap().is_none()); + assert!(registry.get(&name("grok").id()).is_none()); + } + + #[tokio::test] + async fn remove_refuses_the_provider_in_use() { + let (providers, _registry, _credentials) = service("remove-active"); + let url = serve_once(OK_TWO_MODELS).await; + providers.add(name("grok"), &url, None).await.unwrap(); + + let error = providers + .remove(&name("grok"), &name("grok").id()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("in use"), "{error:#}"); + assert_eq!(providers.entries().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn load_registers_saved_providers_without_fetching() { + let path = temp_path("load"); + CustomProviderStore::new(path.clone()) + .put(CustomProviderEntry { + name: name("grok"), + url: "http://localhost:1/v1".into(), + }) + .await + .unwrap(); + let registry = Arc::new(ProviderRegistry::new([])); + let providers = CustomProviders::new( + CustomProviderStore::new(path), + Arc::clone(®istry), + Arc::new(InMemoryCredentialStore::new()), + ); + + providers.load().await.unwrap(); + + let provider = registry.get(&name("grok").id()).unwrap(); + assert!(provider.models().is_empty()); + } +} diff --git a/crates/alan/src/core/mod.rs b/crates/alan/src/core/mod.rs index 96a2f62..3d17efe 100644 --- a/crates/alan/src/core/mod.rs +++ b/crates/alan/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod action; pub mod chat; pub mod command; pub mod completion; +pub mod custom_providers; pub mod paths; pub mod permissions; pub mod permissions_store; diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 59be857..d89de36 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -7,6 +7,7 @@ mod login_overlay; mod root; mod views; +use crate::core::custom_providers::{self, CustomProviderStore, CustomProviders}; use crate::core::permissions::AlanPermissionManager; use crate::core::permissions::ToolPolicy; use crate::core::permissions_store; @@ -23,7 +24,7 @@ use alan_tui::Runtime; use llm::ReasoningEffort; use providers::{ DeepSeekProvider, FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider, - Provider, ProviderRegistry, ZaiProvider, bind_model, + Provider, ProviderId, ProviderRegistry, ZaiProvider, bind_model, }; use std::path::PathBuf; use std::sync::Arc; @@ -56,13 +57,21 @@ async fn main() -> anyhow::Result<()> { credential_store.clone() as Arc; let local_provider = load_local_provider().await?; let providers = build_providers(&credential_store_for_providers, &local_provider)?; - let session_manager = Arc::new(SessionManager::new(sessions_path()?)); - let resumed_session = load_resumed_session(&session_manager).await?; - let model = select_model(&providers, &settings, resumed_session.as_ref())?; let registry = Arc::new(ProviderRegistry::with_local_provider( providers, Arc::clone(&local_provider), )); + let custom_providers = Arc::new(CustomProviders::new( + CustomProviderStore::new(custom_providers::default_providers_path()?), + Arc::clone(®istry), + Arc::clone(&credential_store_for_providers), + )); + if let Err(error) = custom_providers.load().await { + tracing::warn!(error = %error, "failed to load custom providers"); + } + let session_manager = Arc::new(SessionManager::new(sessions_path()?)); + let resumed_session = load_resumed_session(&session_manager).await?; + let model = select_model(®istry, &settings, resumed_session.as_ref())?; let current_dir = std::env::current_dir()?; @@ -170,7 +179,7 @@ async fn load_resumed_session( } fn select_model( - providers: &[Arc], + registry: &ProviderRegistry, settings: &Settings, resumed_session: Option<&agent::Session>, ) -> anyhow::Result { @@ -184,9 +193,8 @@ fn select_model( .unwrap_or_else(|| DEFAULT_MODEL.into()), ), }; - let provider = providers - .iter() - .find(|provider| provider.id().0 == provider_id) + let provider = registry + .get(&ProviderId::new(provider_id)) .ok_or_else(|| anyhow::anyhow!("Provider not found: {provider_id}"))?; let server_tools = server_tools( provider.server_tools(), From 798074613a62379f382faeacf7a0c6481656d6e2 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 17:59:36 +0530 Subject: [PATCH 04/36] Add /provider to add, edit and remove Chat Completions providers --- crates/alan/src/core/command.rs | 15 + crates/alan/src/core/custom_providers.rs | 7 - crates/alan/src/main.rs | 2 + crates/alan/src/provider_overlay.rs | 566 ++++++++++++++++++ crates/alan/src/root.rs | 5 + .../src/views/components/chat_view/mod.rs | 12 + .../src/views/components/chat_view/models.rs | 254 +++++++- 7 files changed, 844 insertions(+), 17 deletions(-) create mode 100644 crates/alan/src/provider_overlay.rs diff --git a/crates/alan/src/core/command.rs b/crates/alan/src/core/command.rs index 07fc3bd..cdbb4ef 100644 --- a/crates/alan/src/core/command.rs +++ b/crates/alan/src/core/command.rs @@ -33,6 +33,8 @@ pub enum SlashCommand { // Manage local models (add, remove, edit). Local, Profile, + // Add, edit or remove a Chat Completions provider. + Provider, Quit, } @@ -87,6 +89,7 @@ impl SlashCommand { | Self::Rename | Self::Local | Self::Profile + | Self::Provider ) } @@ -109,6 +112,7 @@ impl SlashCommand { Self::ToolStrict => "allow only exact commands already approved", Self::Local => "manage local models (add, remove, edit)", Self::Profile => "save and switch named model profiles", + Self::Provider => "add, edit or remove a Chat Completions provider", Self::Quit => "abort and quit", } } @@ -351,6 +355,17 @@ mod tests { assert!(SlashCommand::Profile.takes_args()); } + #[test] + fn parses_provider_subcommands() { + for sub in ["add", "edit", "remove"] { + assert_eq!( + SlashCommand::parse_with_args(&format!("/provider {sub}")), + Some((SlashCommand::Provider, sub)) + ); + } + assert!(SlashCommand::Provider.takes_args()); + } + #[test] fn parses_local_command() { assert_eq!(SlashCommand::parse("/local"), Some(SlashCommand::Local)); diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 0b02901..f831195 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -1,13 +1,6 @@ //! Providers added with `/provider add`: any server that speaks Chat //! Completions. Each is a name and a base URL in `providers.json`. Its key, //! if any, lives in `auth.json` under the same name so `/login` can change it. -#![cfg_attr( - not(test), - expect( - dead_code, - reason = "add, edit and remove are wired to /provider in the next commit" - ) -)] use anyhow::{Context, Result}; use providers::{ diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index d89de36..1172a77 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -4,6 +4,7 @@ mod local_model_overlay; mod local_model_store; mod logging; mod login_overlay; +mod provider_overlay; mod root; mod views; @@ -110,6 +111,7 @@ async fn main() -> anyhow::Result<()> { controller, registry, credential_store, + custom_providers, permission_handler, policy, skill_roots, diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs new file mode 100644 index 0000000..50f2d22 --- /dev/null +++ b/crates/alan/src/provider_overlay.rs @@ -0,0 +1,566 @@ +//! `/provider add` and `/provider edit`: the form for a custom provider. + +use crate::core::custom_providers::{CustomProviderEntry, CustomProviders, ProviderName}; +use crate::root::AlanAction; +use crate::views::theme; +use alan_tui::context::Context; +use alan_tui::{ActionStatus, Component, RenderContext, TaskError}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap}; +use std::sync::Arc; + +const FIELD_COUNT: usize = 4; +const FIELD_LABELS: [&str; FIELD_COUNT] = ["Name", "URL", "API", "API Key"]; +const NAME: usize = 0; +const URL: usize = 1; +const API: usize = 2; +const API_KEY: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Add, + Edit, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct FormFields { + name: String, + url: String, + api_key: String, +} + +impl FormFields { + fn field(&self, index: usize) -> &str { + match index { + NAME => &self.name, + URL => &self.url, + API_KEY => &self.api_key, + _ => "", + } + } + + fn submission(&self) -> anyhow::Result { + let name = ProviderName::parse(&self.name)?; + let url = self.url.trim(); + anyhow::ensure!(!url.is_empty(), "URL is required"); + let key = self.api_key.trim(); + Ok(Submission { + name, + url: url.to_owned(), + key: (!key.is_empty()).then(|| key.to_owned()), + }) + } +} + +/// What the form sends once its fields parse. +#[derive(Debug)] +struct Submission { + name: ProviderName, + url: String, + key: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum OverlayState { + Editing, + Checking, + /// Saved; the server lists this many models. + Saved(usize), + Error(String), +} + +/// What a key press needs from the host beyond the overlay's own state. +#[derive(Debug, PartialEq, Eq)] +enum KeyOutcome { + Redraw, + Submit, + Close, + Ignore, +} + +pub struct ProviderOverlay { + providers: Arc, + mode: Mode, + fields: FormFields, + focused: usize, + state: OverlayState, +} + +impl ProviderOverlay { + pub fn add(providers: Arc) -> Self { + Self::new(providers, Mode::Add, FormFields::default(), NAME) + } + + /// The name is fixed: `auth.json`, `settings.json` and profiles refer to + /// the provider by it. + pub fn edit( + providers: Arc, + entry: CustomProviderEntry, + key: Option, + ) -> Self { + let fields = FormFields { + name: entry.name.to_string(), + url: entry.url, + api_key: key.unwrap_or_default(), + }; + Self::new(providers, Mode::Edit, fields, URL) + } + + fn new( + providers: Arc, + mode: Mode, + fields: FormFields, + focused: usize, + ) -> Self { + Self { + providers, + mode, + fields, + focused, + state: OverlayState::Editing, + } + } + + fn on_key(&mut self, key: KeyEvent) -> KeyOutcome { + match &self.state { + OverlayState::Editing => self.on_editing_key(key), + OverlayState::Checking => KeyOutcome::Ignore, + OverlayState::Saved(_) => match key.code { + KeyCode::Enter | KeyCode::Esc => KeyOutcome::Close, + _ => KeyOutcome::Ignore, + }, + // Back to the form with everything typed, to fix the URL or key. + OverlayState::Error(_) => match key.code { + KeyCode::Enter | KeyCode::Esc => { + self.state = OverlayState::Editing; + KeyOutcome::Redraw + } + _ => KeyOutcome::Ignore, + }, + } + } + + fn on_editing_key(&mut self, key: KeyEvent) -> KeyOutcome { + match key.code { + KeyCode::Esc => KeyOutcome::Close, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyOutcome::Close + } + KeyCode::Enter => KeyOutcome::Submit, + KeyCode::Tab | KeyCode::Down => self.move_focus(1), + KeyCode::BackTab | KeyCode::Up => self.move_focus(-1), + KeyCode::Backspace => self.edit_focused(|field| { + field.pop(); + }), + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.edit_focused(|field| field.push(c)) + } + _ => KeyOutcome::Ignore, + } + } + + fn move_focus(&mut self, delta: isize) -> KeyOutcome { + self.focused = (self.focused as isize + delta).rem_euclid(FIELD_COUNT as isize) as usize; + KeyOutcome::Redraw + } + + fn edit_focused(&mut self, edit: impl FnOnce(&mut String)) -> KeyOutcome { + let field = match self.focused { + NAME if self.mode == Mode::Add => &mut self.fields.name, + URL => &mut self.fields.url, + API_KEY => &mut self.fields.api_key, + _ => return KeyOutcome::Ignore, + }; + edit(field); + KeyOutcome::Redraw + } + + fn submit(&mut self, cx: &mut Context<'_, Self, AlanAction>) { + let submission = match self.fields.submission() { + Ok(submission) => submission, + Err(error) => { + self.state = OverlayState::Error(error.to_string()); + cx.notify(); + return; + } + }; + let providers = Arc::clone(&self.providers); + let mode = self.mode; + cx.spawn( + async move { + let Submission { name, url, key } = submission; + match mode { + Mode::Add => providers.add(name, &url, key).await, + Mode::Edit => providers.update(name, &url, key).await, + } + .map_err(|error| TaskError(format!("{error:#}").into())) + }, + |result, overlay, cx| { + overlay.state = match result { + Ok(count) => OverlayState::Saved(count), + Err(error) => OverlayState::Error(error.to_string()), + }; + cx.notify(); + }, + ); + self.state = OverlayState::Checking; + cx.notify(); + } +} + +impl Component for ProviderOverlay { + fn handle_action( + &mut self, + action: &AlanAction, + cx: &mut Context<'_, Self, AlanAction>, + ) -> ActionStatus { + match action { + AlanAction::Paste(text) => { + if self.state == OverlayState::Editing + && self.edit_focused(|field| field.push_str(text)) == KeyOutcome::Redraw + { + cx.notify(); + } + ActionStatus::Handled + } + AlanAction::Raw(Event::Key(key)) => { + if key.kind == KeyEventKind::Press { + match self.on_key(*key) { + KeyOutcome::Redraw => cx.notify(), + KeyOutcome::Submit => self.submit(cx), + KeyOutcome::Close => cx.close_overlay(), + KeyOutcome::Ignore => {} + } + } + ActionStatus::Handled + } + AlanAction::Raw(_) => ActionStatus::Handled, + _ => ActionStatus::Continue, + } + } + + fn render(&self, frame: &mut Frame, area: Rect, _cx: &RenderContext<'_, '_, AlanAction>) { + render_provider_overlay( + frame, + area, + &self.fields, + self.focused, + &self.state, + self.mode, + ); + } +} + +fn render_provider_overlay( + frame: &mut Frame, + area: Rect, + fields: &FormFields, + focused: usize, + state: &OverlayState, + mode: Mode, +) { + let width = area.width.saturating_sub(4).min(88); + let height = area.height.saturating_sub(2).min(18); + if width < 12 || height < 6 { + return; + } + let popup = Rect::new( + area.x + (area.width - width) / 2, + area.y + (area.height - height) / 2, + width, + height, + ); + frame.render_widget(Clear, popup); + let accent = Style::default().fg(theme::PROMPT_FG); + let muted = Style::default().fg(theme::TOOL_FG); + + let (heading, footer) = match state { + OverlayState::Editing => ( + match mode { + Mode::Add => "Add Provider", + Mode::Edit => "Edit Provider", + }, + if width >= 60 { + " Tab move Enter check and save Esc cancel " + } else { + " Tab · Enter · Esc " + }, + ), + OverlayState::Checking => ("Checking…", ""), + OverlayState::Saved(_) => ("Saved", " Enter / Esc close "), + OverlayState::Error(_) => ("Not saved", " Enter / Esc back to the form "), + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(accent) + .style(Style::default().bg(theme::EDITOR_BG).fg(theme::USER_FG)) + .title( + Line::from(Span::styled( + format!(" {heading} "), + accent.add_modifier(Modifier::BOLD), + )) + .style(accent), + ) + .title_alignment(Alignment::Center) + .title_bottom(Line::from(footer).style(muted)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let url = fields.url.trim().trim_end_matches('/'); + match state { + OverlayState::Editing => render_fields(frame, inner, fields, focused, accent, muted), + OverlayState::Checking => frame.render_widget( + Paragraph::new(format!(" Checking {url}/models…")).style(accent), + inner, + ), + OverlayState::Saved(count) => frame.render_widget( + Paragraph::new(format!( + " Saved {}: {count} model{}. Pick one with /models.", + fields.name.trim(), + if *count == 1 { "" } else { "s" } + )) + .style(accent), + inner, + ), + OverlayState::Error(error) => frame.render_widget( + Paragraph::new(format!(" {error}")) + .style(Style::default().fg(theme::TOOL_ERROR_FG)) + .wrap(Wrap { trim: false }), + inner, + ), + } +} + +fn render_fields( + frame: &mut Frame, + inner: Rect, + fields: &FormFields, + focused: usize, + accent: Style, + muted: Style, +) { + let max_label = FIELD_LABELS + .iter() + .map(|label| label.len()) + .max() + .unwrap_or(0); + for (i, label) in FIELD_LABELS.iter().enumerate() { + let y = inner.y + i as u16 * 2; + if y + 2 > inner.bottom() { + break; + } + let is_focused = i == focused; + let value_style = if is_focused { + Style::default() + .fg(theme::SELECTION_FG) + .bg(theme::SELECTION_BG) + } else { + Style::default().fg(theme::EDITOR_FG) + }; + let value = match i { + API => Span::styled("Chat Completions", value_style), + API_KEY if fields.api_key.is_empty() => Span::styled("optional", muted), + API_KEY => Span::styled("•".repeat(fields.api_key.chars().count()), value_style), + _ if fields.field(i).is_empty() => Span::styled("required", muted), + _ => Span::styled(fields.field(i), value_style), + }; + let line = Line::from(vec![ + Span::styled(if is_focused { " › " } else { " " }, accent), + Span::styled( + format!("{label:>max_label$} "), + if is_focused { + accent.add_modifier(Modifier::BOLD) + } else { + muted + }, + ), + Span::styled(": ", muted), + value, + ]); + frame.render_widget( + Paragraph::new(line).style(if is_focused { + Style::default().bg(theme::SELECTION_BG) + } else { + Style::default() + }), + Rect::new(inner.x, y, inner.width, 1), + ); + // No colon in the hint: the alignment test counts one per field row. + if i == URL && fields.url.is_empty() { + frame.render_widget( + Paragraph::new(Span::styled( + format!( + "{}the part before /chat/completions, e.g. https…/v1", + " ".repeat(max_label + 7) + ), + muted, + )), + Rect::new(inner.x, y + 1, inner.width, 1), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::custom_providers::CustomProviderStore; + use providers::{InMemoryCredentialStore, ProviderRegistry}; + use ratatui::{Terminal, backend::TestBackend}; + + fn providers() -> Arc { + Arc::new(CustomProviders::new( + CustomProviderStore::new(std::env::temp_dir().join(format!( + "alan-provider-overlay-test-{}.json", + std::process::id() + ))), + Arc::new(ProviderRegistry::new([])), + Arc::new(InMemoryCredentialStore::new()), + )) + } + + fn press(overlay: &mut ProviderOverlay, code: KeyCode) -> KeyOutcome { + overlay.on_key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + fn type_text(overlay: &mut ProviderOverlay, text: &str) { + for c in text.chars() { + press(overlay, KeyCode::Char(c)); + } + } + + fn screen(fields: &FormFields, state: &OverlayState) -> String { + let mut terminal = Terminal::new(TestBackend::new(80, 18)).unwrap(); + terminal + .draw(|frame| { + render_provider_overlay(frame, frame.area(), fields, NAME, state, Mode::Add) + }) + .unwrap(); + terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + #[test] + fn typing_fills_the_focused_field_and_tab_moves_on() { + let mut overlay = ProviderOverlay::add(providers()); + type_text(&mut overlay, "grok"); + press(&mut overlay, KeyCode::Tab); + type_text(&mut overlay, "https://api.x.ai/v1"); + press(&mut overlay, KeyCode::Tab); + assert_eq!(press(&mut overlay, KeyCode::Char('x')), KeyOutcome::Ignore); + press(&mut overlay, KeyCode::Tab); + type_text(&mut overlay, "xai-1"); + + assert_eq!(overlay.fields.name, "grok"); + assert_eq!(overlay.fields.url, "https://api.x.ai/v1"); + assert_eq!(overlay.fields.api_key, "xai-1"); + } + + #[test] + fn the_name_is_fixed_when_editing() { + let entry = CustomProviderEntry { + name: ProviderName::parse("grok").unwrap(), + url: "https://api.x.ai/v1".into(), + }; + let mut overlay = ProviderOverlay::edit(providers(), entry, Some("xai-1".into())); + assert_eq!(overlay.focused, URL); + press(&mut overlay, KeyCode::BackTab); + assert_eq!(press(&mut overlay, KeyCode::Char('x')), KeyOutcome::Ignore); + assert_eq!(overlay.fields.name, "grok"); + assert_eq!(overlay.fields.api_key, "xai-1"); + } + + #[test] + fn an_error_goes_back_to_the_form_with_fields_kept() { + let mut overlay = ProviderOverlay::add(providers()); + type_text(&mut overlay, "grok"); + overlay.state = OverlayState::Error("checking …: HTTP 401".into()); + + assert_eq!(press(&mut overlay, KeyCode::Enter), KeyOutcome::Redraw); + assert_eq!(overlay.state, OverlayState::Editing); + assert_eq!(overlay.fields.name, "grok"); + } + + #[test] + fn saved_closes_and_esc_cancels() { + let mut overlay = ProviderOverlay::add(providers()); + assert_eq!(press(&mut overlay, KeyCode::Esc), KeyOutcome::Close); + overlay.state = OverlayState::Saved(3); + assert_eq!(press(&mut overlay, KeyCode::Enter), KeyOutcome::Close); + overlay.state = OverlayState::Checking; + assert_eq!(press(&mut overlay, KeyCode::Esc), KeyOutcome::Ignore); + } + + #[test] + fn submission_trims_and_treats_a_blank_key_as_none() { + let fields = FormFields { + name: " grok ".into(), + url: " https://api.x.ai/v1 ".into(), + api_key: " ".into(), + }; + let submission = fields.submission().unwrap(); + assert_eq!(submission.name.to_string(), "grok"); + assert_eq!(submission.url, "https://api.x.ai/v1"); + assert_eq!(submission.key, None); + } + + #[test] + fn submission_requires_a_url_and_a_valid_name() { + let no_url = FormFields { + name: "grok".into(), + ..FormFields::default() + }; + assert!(no_url.submission().is_err()); + let bad_name = FormFields { + name: "Grok".into(), + url: "http://x".into(), + ..FormFields::default() + }; + assert!(bad_name.submission().is_err()); + } + + #[test] + fn labels_align_and_the_key_is_masked() { + let fields = FormFields { + name: "grok".into(), + url: "https://api.x.ai/v1".into(), + api_key: "xai-secret".into(), + }; + let screen = screen(&fields, &OverlayState::Editing); + assert!(!screen.contains("xai-secret")); + assert!(screen.contains("••••••••••")); + let rows: Vec = screen + .chars() + .collect::>() + .chunks(80) + .map(|row| row.iter().collect()) + .collect(); + // Char positions, not bytes: the focus marker `›` is multibyte. + let colons: Vec = rows + .iter() + .filter_map(|row| row.chars().position(|c| c == ':')) + .collect(); + assert_eq!(colons.len(), FIELD_COUNT, "{rows:#?}"); + assert!(colons.iter().all(|&c| c == colons[0]), "{colons:?}"); + } + + #[test] + fn an_error_shows_the_reason() { + let screen = screen( + &FormFields::default(), + &OverlayState::Error("checking http://x/v1/models: HTTP 401".into()), + ); + assert!(screen.contains("HTTP 401")); + } +} diff --git a/crates/alan/src/root.rs b/crates/alan/src/root.rs index 5449ecb..1b56126 100644 --- a/crates/alan/src/root.rs +++ b/crates/alan/src/root.rs @@ -21,6 +21,7 @@ use std::path::PathBuf; use crate::core::ImageAttachment; use crate::core::chat::ChatController; +use crate::core::custom_providers::CustomProviders; use crate::core::permissions::Answer; use crate::core::permissions::PermissionHandler; use crate::core::permissions::ToolPolicy; @@ -66,6 +67,7 @@ pub struct PromptSubmission { pub struct AlanRoot { providers: Arc, credentials: Arc, + custom_providers: Arc, /// Tool-permission request handler routed to the chat view. permission_handler: PermissionHandler, /// Tool-permission policy, shared with the permission manager. @@ -86,6 +88,7 @@ impl AlanRoot { chat: ChatController, providers: Arc, credentials: Arc, + custom_providers: Arc, permission_handler: PermissionHandler, policy: ToolPolicy, skill_roots: Vec, @@ -93,6 +96,7 @@ impl AlanRoot { Self { providers, credentials, + custom_providers, permission_handler, policy, skill_roots, @@ -143,6 +147,7 @@ impl Component for AlanRoot { .take() .expect("chat component installed once"), Arc::clone(&self.providers), + Arc::clone(&self.custom_providers), self.permission_handler.clone(), self.policy.clone(), self.skill_roots.clone(), diff --git a/crates/alan/src/views/components/chat_view/mod.rs b/crates/alan/src/views/components/chat_view/mod.rs index 4709996..7677436 100644 --- a/crates/alan/src/views/components/chat_view/mod.rs +++ b/crates/alan/src/views/components/chat_view/mod.rs @@ -13,6 +13,7 @@ mod session; mod steering; use crate::core::chat::ChatController; +use crate::core::custom_providers::CustomProviders; use crate::core::permissions::PermissionHandler; use crate::core::permissions::PermissionRequest; use crate::core::permissions::{Answer, Policy, ToolPolicy}; @@ -56,6 +57,13 @@ pub(crate) enum LocalPick { Edit, } +/// Action chosen from the custom-provider picker. +#[derive(Clone, Copy)] +pub(crate) enum ProviderPick { + Edit, + Remove, +} + #[derive(Debug, Clone, Copy)] pub struct LoginRequested; @@ -71,6 +79,7 @@ pub struct ChatView { permission: Option>, providers: Arc, + custom_providers: Arc, permission_handler: PermissionHandler, policy: ToolPolicy, /// Subscription to the permission-request stream. @@ -90,6 +99,7 @@ impl ChatView { pub fn new( controller: ChatController, providers: Arc, + custom_providers: Arc, permission_handler: PermissionHandler, policy: ToolPolicy, skill_roots: Vec, @@ -102,6 +112,7 @@ impl ChatView { pending_permission: None, permission: None, providers, + custom_providers, permission_handler, policy, permission_subscription: None, @@ -252,6 +263,7 @@ impl ChatView { Cmd::New => session::start_new_session(self, cx), Cmd::Models => models::open_models_picker(self, cx), Cmd::Local => models::handle_local_command(self, &submission.text, cx), + Cmd::Provider => models::handle_provider_command(self, &submission.text, cx), Cmd::Profile => profiles::handle_profile_command(self, &submission.text, cx), Cmd::Quit => cx.quit(), } diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index f049b0a..6a625e0 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -1,5 +1,5 @@ -//! Model, provider, and local-model commands: `/models`, `/providers`, -//! `/local`, and the model picker overlays. +//! Model and provider commands: `/models`, `/model-providers`, +//! `/provider`, `/local`, and the model picker overlays. use crate::core::SlashCommand; use crate::core::server_tools::server_tools; @@ -13,26 +13,41 @@ use providers::{ }; use std::sync::Arc; -use super::LocalPick; +use super::{LocalPick, ProviderPick}; use crate::views::components::{SearchListEvent, SearchListOverlay}; use super::{ChatView, parse_provider_order, persist_provider_order}; +use crate::core::custom_providers::{CustomProviderEntry, ProviderName}; use crate::local_model_overlay::LocalModelOverlay; +use crate::provider_overlay::ProviderOverlay; /// Provider id reserved for locally-hosted models. const LOCAL_PROVIDER_ID: &str = "local"; /// `/models`: open the model picker overlay. pub(crate) fn open_models_picker(view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>) { + open_model_picker(view, cx, "Select Model".to_owned(), None); +} + +/// The model picker. With `replacing`, that provider's models are hidden and +/// the provider is removed once the switch to the chosen model succeeds. +fn open_model_picker( + view: &mut ChatView, + cx: &mut Context<'_, ChatView, AlanAction>, + title: String, + replacing: Option, +) { let providers = Arc::clone(&view.providers); + let hidden = replacing.as_ref().map(ProviderName::id); let picker = cx.open_overlay(SearchListOverlay::new( - "Select Model", - model_labels(&providers), + title, + model_labels(&picker_models(&providers, hidden.as_ref())), )); let providers_for_fetch = Arc::clone(&providers); let providers_for_items = Arc::clone(&providers); + let hidden_for_items = hidden.clone(); let _ = cx.spawn( async move { fetch_all_models(&providers_for_fetch).await; @@ -40,23 +55,32 @@ pub(crate) fn open_models_picker(view: &mut ChatView, cx: &mut Context<'_, ChatV }, move |result, _view, cx| { if result.is_ok() { - let items = model_labels(&providers_for_items); + let items = model_labels(&picker_models( + &providers_for_items, + hidden_for_items.as_ref(), + )); let _ = cx.update(picker, |p| p.set_items(items)); } }, ); + let custom_providers = Arc::clone(&view.custom_providers); view.model_subscription = Some(cx.subscribe::( picker, move |event, view, _picker, cx| { let SearchListEvent::Chosen(index) = event else { return; }; - let Some(model_info) = all_models(&providers).into_iter().nth(*index) else { + let Some(model_info) = picker_models(&providers, hidden.as_ref()) + .into_iter() + .nth(*index) + else { return; }; let agent = view.controller.agent(); let providers = Arc::clone(&providers); + let custom_providers = Arc::clone(&custom_providers); + let replacing = replacing.clone(); let _ = cx.spawn( async move { let mut options = agent.model_options().await; @@ -77,14 +101,35 @@ pub(crate) fn open_models_picker(view: &mut ChatView, cx: &mut Context<'_, ChatV persist_model(&model_info.id, &model_info.provider) .await .map_err(|e| TaskError(e.into()))?; - Ok((name, max_context, reasoning_effort)) + + // Only after the switch: until then the old provider's + // key is still the one requests go out with. + let removal = match replacing { + Some(replaced) => { + let result = custom_providers + .remove(&replaced, &model_info.provider) + .await; + Some(result.map(|()| replaced).map_err(|e| format!("{e:#}"))) + } + None => None, + }; + Ok((name, max_context, reasoning_effort, removal)) }, move |result, view, cx| { match result { - Ok((name, max_context, reasoning_effort)) => { + Ok((name, max_context, reasoning_effort, removal)) => { view.controller.set_max_context(max_context); view.controller.set_reasoning_effort(reasoning_effort); view.controller.apply_model_switch(name); + match removal { + Some(Ok(replaced)) => view + .controller + .push_info(format!("removed provider: {replaced}")), + Some(Err(error)) => view.controller.push_info(format!( + "switched models, but failed to remove the provider: {error}" + )), + None => {} + } } Err(e) => view.controller.apply_model_switch_failed(e.to_string()), } @@ -174,6 +219,154 @@ pub(crate) fn handle_local_command( } } +/// `/provider`: add, edit or remove a custom provider. +pub(crate) fn handle_provider_command( + view: &mut ChatView, + text: &str, + cx: &mut Context<'_, ChatView, AlanAction>, +) { + let args = SlashCommand::parse_with_args(text).map(|(_, a)| a.trim().to_owned()); + match args.as_deref() { + Some("add") => { + cx.open_overlay(ProviderOverlay::add(Arc::clone(&view.custom_providers))); + } + Some("edit") => open_provider_picker(view, cx, ProviderPick::Edit), + Some("remove") => open_provider_picker(view, cx, ProviderPick::Remove), + _ => view + .controller + .push_info("usage: /provider ".to_owned()), + } +} + +/// List saved providers in a picker, then edit or remove the chosen one. +fn open_provider_picker( + view: &mut ChatView, + cx: &mut Context<'_, ChatView, AlanAction>, + pick: ProviderPick, +) { + let providers = Arc::clone(&view.custom_providers); + cx.spawn( + async move { providers.entries().await.map_err(super::to_task) }, + move |result, view, cx| { + let entries = match result { + Ok(entries) if entries.is_empty() => { + view.controller + .push_info("no providers yet; add one with /provider add".to_owned()); + cx.notify(); + return; + } + Ok(entries) => entries, + Err(error) => { + view.controller + .push_info(format!("failed to read providers: {error}")); + cx.notify(); + return; + } + }; + let title = match pick { + ProviderPick::Edit => "Edit Provider", + ProviderPick::Remove => "Remove Provider", + }; + let labels = entries + .iter() + .map(|entry| format!("{} — {}", entry.name, entry.url)) + .collect(); + let picker = cx.open_overlay(SearchListOverlay::new(title, labels)); + cx.subscribe_once::( + picker, + move |event, view, _picker, cx| { + let SearchListEvent::Chosen(index) = event else { + return; + }; + let Some(entry) = entries.get(*index).cloned() else { + return; + }; + match pick { + ProviderPick::Edit => edit_provider(view, cx, entry), + ProviderPick::Remove => remove_provider(view, cx, entry.name), + } + }, + ); + }, + ); +} + +fn edit_provider( + view: &mut ChatView, + cx: &mut Context<'_, ChatView, AlanAction>, + entry: CustomProviderEntry, +) { + let providers = Arc::clone(&view.custom_providers); + cx.spawn( + async move { + let key = providers.key(&entry.name).await.map_err(super::to_task)?; + Ok((entry, key)) + }, + move |result, view, cx| match result { + Ok((entry, key)) => { + cx.open_overlay(ProviderOverlay::edit( + Arc::clone(&view.custom_providers), + entry, + key, + )); + } + Err(error) => { + view.controller + .push_info(format!("failed to read the provider's key: {error}")); + cx.notify(); + } + }, + ); +} + +/// The provider of the running model is swapped out first: the model picker +/// opens without its models, and it goes once the switch succeeds. +fn remove_provider( + view: &mut ChatView, + cx: &mut Context<'_, ChatView, AlanAction>, + name: ProviderName, +) { + let agent = view.controller.agent(); + cx.spawn( + async move { Ok::<_, TaskError>(agent.info().await.provider) }, + move |result, view, cx| { + let Ok(active) = result else { + return; + }; + if active == name.id() { + open_model_picker(view, cx, format!("Replace {name} with"), Some(name)); + } else { + remove_idle_provider(view, cx, name, active); + } + }, + ); +} + +fn remove_idle_provider( + view: &mut ChatView, + cx: &mut Context<'_, ChatView, AlanAction>, + name: ProviderName, + active: ProviderId, +) { + let providers = Arc::clone(&view.custom_providers); + cx.spawn( + async move { + providers + .remove(&name, &active) + .await + .map_err(super::to_task)?; + Ok(name) + }, + |result, view, cx| { + view.controller.push_info(match result { + Ok(name) => format!("removed provider: {name}"), + Err(error) => format!("failed to remove provider: {error}"), + }); + cx.notify(); + }, + ); +} + /// Open the local model overlay (for adding or editing). pub(crate) fn open_local_model_overlay( view: &mut ChatView, @@ -306,9 +499,17 @@ pub(crate) fn all_models(providers: &ProviderRegistry) -> Vec { .collect() } -pub(crate) fn model_labels(providers: &ProviderRegistry) -> Vec { +/// The models a picker offers: every provider's, minus `hidden`'s. +fn picker_models(providers: &ProviderRegistry, hidden: Option<&ProviderId>) -> Vec { all_models(providers) .into_iter() + .filter(|model| Some(&model.provider) != hidden) + .collect() +} + +fn model_labels(models: &[ModelInfo]) -> Vec { + models + .iter() .map(|m| format!("{} — {}", m.provider, m.name)) .collect() } @@ -357,3 +558,36 @@ pub(crate) async fn persist_model(model_id: &str, provider_id: &ProviderId) -> a }) .await } + +#[cfg(test)] +mod tests { + use super::*; + use providers::{CustomProvider, InMemoryCredentialStore}; + + fn provider(name: &str, model: &str) -> Arc { + Arc::new(CustomProvider::new( + ProviderId::new(name), + "http://localhost:11434/v1", + Arc::new(InMemoryCredentialStore::new()), + vec![ModelInfo::new(model, ProviderId::new(name))], + )) + } + + #[test] + fn picker_models_hides_only_the_replaced_provider() { + let registry = + ProviderRegistry::new([provider("grok", "grok-4"), provider("groq", "llama")]); + + let all: Vec<_> = picker_models(®istry, None) + .into_iter() + .map(|m| m.id) + .collect(); + let without_grok: Vec<_> = picker_models(®istry, Some(&ProviderId::new("grok"))) + .into_iter() + .map(|m| m.id) + .collect(); + + assert_eq!(all, ["grok-4", "llama"]); + assert_eq!(without_grok, ["llama"]); + } +} From 8b8490b3f3c91e2f1307aceef50d31c1ac0cb778 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 18:00:10 +0530 Subject: [PATCH 05/36] Start on the default model when the saved provider is gone --- crates/alan/src/main.rs | 79 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 1172a77..bfded22 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -13,7 +13,9 @@ use crate::core::permissions::AlanPermissionManager; use crate::core::permissions::ToolPolicy; use crate::core::permissions_store; use crate::core::server_tools::server_tools; -use crate::core::settings::{DEFAULT_MODEL, PatchSettings, Settings, SettingsStore}; +use crate::core::settings::{ + DEFAULT_MODEL, DEFAULT_PROVIDER, PatchSettings, Settings, SettingsStore, +}; use crate::core::update::current_version; use crate::core::{ChatController, SlashCommand}; use crate::keymap::AlanKeyMapper; @@ -72,7 +74,7 @@ async fn main() -> anyhow::Result<()> { } let session_manager = Arc::new(SessionManager::new(sessions_path()?)); let resumed_session = load_resumed_session(&session_manager).await?; - let model = select_model(®istry, &settings, resumed_session.as_ref())?; + let (model, startup_notice) = select_model(®istry, &settings, resumed_session.as_ref())?; let current_dir = std::env::current_dir()?; @@ -99,6 +101,9 @@ async fn main() -> anyhow::Result<()> { let model_name = agent.info().await.name; let mut controller = ChatController::new(agent, model_name); + if let Some(notice) = startup_notice { + controller.push_info(notice); + } if was_resumed { controller.restore_session_history().await; @@ -180,30 +185,45 @@ async fn load_resumed_session( Ok(Some(session_manager.get_session(&session_id, &cwd).await?)) } +/// The model to start on, and a notice when the saved provider no longer +/// exists: removed with `/provider remove`, or a typo in `ALAN_PROVIDER`. fn select_model( registry: &ProviderRegistry, settings: &Settings, resumed_session: Option<&agent::Session>, -) -> anyhow::Result { +) -> anyhow::Result<(providers::Model, Option)> { let (provider_id, model_id) = match resumed_session { Some(session) => (session.provider.as_str(), session.model.clone()), None => ( - settings.provider.as_deref().unwrap_or("openrouter"), + settings.provider.as_deref().unwrap_or(DEFAULT_PROVIDER), settings .model .clone() .unwrap_or_else(|| DEFAULT_MODEL.into()), ), }; - let provider = registry - .get(&ProviderId::new(provider_id)) - .ok_or_else(|| anyhow::anyhow!("Provider not found: {provider_id}"))?; + let (provider, model_id, notice) = match registry.get(&ProviderId::new(provider_id)) { + Some(provider) => (provider, model_id, None), + None => { + tracing::warn!( + provider = provider_id, + "saved provider not found, starting on the default model" + ); + let provider = registry + .get(&ProviderId::new(DEFAULT_PROVIDER)) + .ok_or_else(|| anyhow::anyhow!("Provider not found: {DEFAULT_PROVIDER}"))?; + let notice = format!( + "provider {provider_id} not found; using {DEFAULT_PROVIDER} — {DEFAULT_MODEL}" + ); + (provider, DEFAULT_MODEL.to_owned(), Some(notice)) + } + }; let server_tools = server_tools( provider.server_tools(), settings.web_fetch == Some(true), settings.web_search == Some(true), ); - Ok(bind_model( + let model = bind_model( provider.as_ref(), &model_id, ModelOptions { @@ -211,7 +231,8 @@ fn select_model( reasoning_effort: settings.reasoning.unwrap_or_default(), extensions: settings.extensions(&model_id), }, - )?) + )?; + Ok((model, notice)) } fn build_tool_policy( @@ -340,3 +361,43 @@ fn configured_session_id() -> anyhow::Result> { } Ok(Some(id)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn registry() -> ProviderRegistry { + let openrouter = OpenRouterProvider::builder("key") + .build() + .expect("builds without network"); + ProviderRegistry::new([Arc::new(openrouter) as Arc]) + } + + #[test] + fn a_missing_provider_falls_back_to_the_default_model() { + let settings = Settings { + provider: Some("gone".into()), + model: Some("some-model".into()), + ..Settings::with_defaults() + }; + + let (model, notice) = select_model(®istry(), &settings, None).unwrap(); + + assert_eq!(model.info().provider, ProviderId::new(DEFAULT_PROVIDER)); + assert_eq!(model.info().id, DEFAULT_MODEL); + assert!(notice.unwrap().contains("gone")); + } + + #[test] + fn a_known_provider_keeps_the_saved_model() { + let settings = Settings { + model: Some("some-model".into()), + ..Settings::with_defaults() + }; + + let (model, notice) = select_model(®istry(), &settings, None).unwrap(); + + assert_eq!(model.info().id, "some-model"); + assert!(notice.is_none()); + } +} From 03fe9c81a290cc021ac15107316a20a303df1aa0 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 18:01:41 +0530 Subject: [PATCH 06/36] Retire /local in favour of /provider --- README.md | 18 +- crates/alan/src/core/command.rs | 34 - crates/alan/src/local_model_overlay.rs | 675 ------------------ crates/alan/src/local_model_store.rs | 101 --- crates/alan/src/main.rs | 28 +- .../src/views/components/chat_view/mod.rs | 7 - .../src/views/components/chat_view/models.rs | 155 +--- crates/alan/src/views/mod.rs | 2 +- crates/providers/src/lib.rs | 4 - crates/providers/src/local.rs | 594 --------------- crates/providers/src/provider.rs | 18 - crates/providers/src/store.rs | 15 - 12 files changed, 12 insertions(+), 1639 deletions(-) delete mode 100644 crates/alan/src/local_model_overlay.rs delete mode 100644 crates/alan/src/local_model_store.rs delete mode 100644 crates/providers/src/local.rs delete mode 100644 crates/providers/src/store.rs diff --git a/README.md b/README.md index 55a9447..eeae7fe 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ A minimal coding agent in your terminal. - [Providers](#providers) - [Usage](#usage) - [Permissions](#permissions) -- [Local models](#local-models) - [Skills](#skills) - [Configuration and data locations](#configuration-and-data-locations) - [Building from source](#building-from-source) @@ -26,7 +25,6 @@ A minimal coding agent in your terminal. - **Compact with focus** — `/summarize-new "focus hint"` summarizes the context and restarts with just what matters. - **Steering** — queue a message mid-run and the agent picks it up without waiting for the response to finish. - **Plan and review modes** — cycle through them with `Shift+Tab` when you want the agent to think before it acts. -- **Local models** — point Alan at any OpenAI-compatible endpoint, no API key required. - **Skills** — type `#name` in the prompt to attach a skill's full instructions to that message. ## Installation @@ -100,7 +98,6 @@ For OpenRouter, you can pin a custom provider for the selected model with `/mode | `/tool-free` | Allow all tool calls without asking | | `/tool-slip` | Approve a command family once, allow its siblings | | `/tool-strict` | Allow only exact commands already approved | -| `/local` | Manage local models (add, remove, edit) | | `/model-providers ` | Pin a custom provider for the current OpenRouter model | | `/help` | List available commands | | `/quit` | Exit Alan | @@ -122,16 +119,6 @@ Alan starts in strict policy and remembers the policy you pick across runs. Swit - `/tool-free` - `/tool-strict` -## Local models - -Alan supports OpenAI-compatible local models. - -```bash -/local add -``` - -This opens an overlay with fields for URL, API, API Key, and Model ID. Alan queries the server's `/models` endpoint and offers the detected models in a searchable picker. Use `/local remove` and `/local edit` to manage your local models. No API key or `/login` is needed for local models. - ## Configuration and data locations Alan stores everything under `~/.alan/` by default: @@ -139,7 +126,6 @@ Alan stores everything under `~/.alan/` by default: - `settings.json` — your current settings and active-profile marker. - `profiles.json` — saved named model/reasoning/web settings profiles. - `auth.json` — provider credentials saved by `/login`. -- `local_models.json` — local models added with `/local`. - `projects/` — per-project permission grants. - `sessions/` — conversation history (append-only JSONL, one file per session). - `skills/` — personal skills, available in every project. @@ -154,7 +140,7 @@ Settings variables override `settings.json` for that run without changing it. St | Variable | Effect | | --- | --- | | `ALAN_HOME` | Parent of the data directory: `ALAN_HOME=/tmp/x` puts everything in `/tmp/x/.alan`. | -| `ALAN_PROVIDER` | Provider id: `openrouter`, `deepseek`, `zai`, or `local`. | +| `ALAN_PROVIDER` | Provider id: `openrouter`, `deepseek` or `zai`. | | `ALAN_MODEL` | Model id for that provider, e.g. `ALAN_MODEL=openai/gpt-4o-mini alan`. | | `ALAN_REASONING_EFFORT` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. | | `ALAN_OPENROUTER_WEB_FETCH` | Turn OpenRouter's web fetch tool on or off: `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`. | @@ -220,4 +206,4 @@ cargo run -p alan ## Requirements - Rust 1.88 or newer (only for building from source) -- An API key for your chosen provider (not needed for local models) +- An API key for your chosen provider diff --git a/crates/alan/src/core/command.rs b/crates/alan/src/core/command.rs index cdbb4ef..52270b2 100644 --- a/crates/alan/src/core/command.rs +++ b/crates/alan/src/core/command.rs @@ -30,8 +30,6 @@ pub enum SlashCommand { ToolFree, ToolSlip, ToolStrict, - // Manage local models (add, remove, edit). - Local, Profile, // Add, edit or remove a Chat Completions provider. Provider, @@ -87,7 +85,6 @@ impl SlashCommand { | Self::ModelProviders | Self::SummarizeNew | Self::Rename - | Self::Local | Self::Profile | Self::Provider ) @@ -110,7 +107,6 @@ impl SlashCommand { Self::ToolFree => "allow all tool calls without asking", Self::ToolSlip => "approve a command family once, allow its siblings", Self::ToolStrict => "allow only exact commands already approved", - Self::Local => "manage local models (add, remove, edit)", Self::Profile => "save and switch named model profiles", Self::Provider => "add, edit or remove a Chat Completions provider", Self::Quit => "abort and quit", @@ -334,7 +330,6 @@ mod tests { assert!(!SlashCommand::Normal.takes_args()); assert!(!SlashCommand::Help.takes_args()); // Argument-taking commands. - assert!(SlashCommand::Local.takes_args()); assert!(SlashCommand::Effort.takes_args()); assert!(SlashCommand::ModelProviders.takes_args()); assert!(SlashCommand::SummarizeNew.takes_args()); @@ -365,33 +360,4 @@ mod tests { } assert!(SlashCommand::Provider.takes_args()); } - - #[test] - fn parses_local_command() { - assert_eq!(SlashCommand::parse("/local"), Some(SlashCommand::Local)); - } - - #[test] - fn parses_local_subcommands() { - assert_eq!( - SlashCommand::parse_with_args("/local add"), - Some((SlashCommand::Local, "add")) - ); - assert_eq!( - SlashCommand::parse_with_args("/local remove"), - Some((SlashCommand::Local, "remove")) - ); - assert_eq!( - SlashCommand::parse_with_args("/local edit"), - Some((SlashCommand::Local, "edit")) - ); - } - - #[test] - fn parses_local_with_empty_args() { - assert_eq!( - SlashCommand::parse_with_args("/local"), - Some((SlashCommand::Local, "")) - ); - } } diff --git a/crates/alan/src/local_model_overlay.rs b/crates/alan/src/local_model_overlay.rs deleted file mode 100644 index e081a16..0000000 --- a/crates/alan/src/local_model_overlay.rs +++ /dev/null @@ -1,675 +0,0 @@ -use crate::root::AlanAction; -use crate::views::theme; -use crate::views::{SearchListEvent, SearchListOverlay}; -use alan_tui::context::Context; -use alan_tui::{ActionStatus, Component, RenderContext, TaskError}; -use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use providers::{LocalApi, LocalModelEntry, LocalProvider, list_local_models}; -use ratatui::Frame; -use ratatui::layout::Alignment; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Clear, Paragraph}; -use std::sync::Arc; - -const FIELD_COUNT: usize = 4; -// 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 { - model_id: String, - url: String, - api_key: String, -} - -impl FormFields { - fn field_mut(&mut self, index: usize) -> Option<&mut String> { - match index { - 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 { - URL => &self.url, - API_KEY => &self.api_key, - MODEL => &self.model_id, - _ => "", - } - } - - fn label(index: usize) -> &'static str { - FIELD_LABELS[index] - } - - fn to_entry(&self) -> Option { - let model_id = self.model_id.trim().to_owned(); - let url = self.url.trim().to_owned(); - if model_id.is_empty() || url.is_empty() { - return None; - } - let api_key = self.api_key.trim().to_owned(); - Some(LocalModelEntry { - model_id, - url, - api: LocalApi::ChatCompletions, - api_key: if api_key.is_empty() { - None - } else { - Some(api_key) - }, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum OverlayState { - Editing, - Saving, - Success, - 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 { - pub fn new(provider: Arc, edit_entry: Option) -> Self { - let (fields, edit_mode) = match edit_entry { - Some(entry) => ( - FormFields { - model_id: entry.model_id, - url: entry.url, - api_key: entry.api_key.unwrap_or_default(), - }, - true, - ), - None => ( - FormFields { - model_id: String::new(), - url: String::new(), - api_key: String::new(), - }, - false, - ), - }; - Self { - provider, - fields, - 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, 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>) { - let Some(entry) = self.fields.to_entry() else { - self.state = OverlayState::Error("Model ID and URL are required".into()); - cx.notify(); - return; - }; - - let provider = Arc::clone(&self.provider); - let edit_mode = self.edit_mode; - - cx.spawn( - async move { - if edit_mode { - provider - .update_model(entry) - .await - .map_err(|e| alan_tui::TaskError(e.to_string().into()))?; - } else { - provider - .add_model(entry) - .await - .map_err(|e| alan_tui::TaskError(e.to_string().into()))?; - } - Ok::<(), alan_tui::TaskError>(()) - }, - move |result, overlay, cx| { - match result { - Ok(()) => { - overlay.state = OverlayState::Success; - } - Err(e) => { - overlay.state = OverlayState::Error(e.to_string()); - } - } - cx.notify(); - }, - ); - - self.state = OverlayState::Saving; - cx.notify(); - } - - fn dismiss(&self, cx: &mut Context<'_, Self, AlanAction>) { - cx.close_overlay(); - } -} - -impl Component for LocalModelOverlay { - fn handle_action( - &mut self, - action: &AlanAction, - cx: &mut Context<'_, Self, AlanAction>, - ) -> ActionStatus { - match action { - AlanAction::Paste(text) => { - if let OverlayState::Editing = &self.state - && let Some(field) = self.fields.field_mut(self.focused) - { - field.push_str(text); - cx.notify(); - } - ActionStatus::Handled - } - AlanAction::Raw(event) => { - let Event::Key(key) = event else { - return ActionStatus::Handled; - }; - if key.kind != KeyEventKind::Press { - return ActionStatus::Handled; - } - match &self.state { - OverlayState::Editing => match key.code { - KeyCode::Esc => { - self.dismiss(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), - // 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) { - field.pop(); - cx.notify(); - } - } - KeyCode::Char(c) - if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => - { - if let Some(field) = self.fields.field_mut(self.focused) { - field.push(c); - cx.notify(); - } - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.dismiss(cx); - } - _ => {} - }, - OverlayState::Saving => {} - OverlayState::Success | OverlayState::Error(_) => { - if matches!(key.code, KeyCode::Enter | KeyCode::Esc) { - self.dismiss(cx); - } - } - } - ActionStatus::Handled - } - _ => ActionStatus::Continue, - } - } - - fn render(&self, frame: &mut Frame, area: Rect, _cx: &RenderContext<'_, '_, AlanAction>) { - render_local_model_overlay( - frame, - area, - &self.fields, - self.focused, - &self.state, - self.edit_mode, - &self.detect, - ); - } -} - -fn render_local_model_overlay( - frame: &mut Frame, - area: Rect, - fields: &FormFields, - 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); - if width < 12 || height < 6 { - return; - } - let popup = Rect::new( - area.x + (area.width - width) / 2, - area.y + (area.height - height) / 2, - width, - height, - ); - frame.render_widget(Clear, popup); - let accent = Style::default().fg(theme::PROMPT_FG); - let muted = Style::default().fg(theme::TOOL_FG); - - let title_label = if edit_mode { "Edit" } else { "Add" }; - let (heading, footer) = match state { - OverlayState::Editing => ( - format!("{} Local Model", title_label), - if width >= 60 { - " Tab move Enter save Esc cancel " - } else { - " Tab · Enter · Esc " - }, - ), - OverlayState::Saving => ("Saving…".to_owned(), ""), - OverlayState::Success => ("Saved".to_owned(), " Enter / Esc close "), - OverlayState::Error(_) => ("Error".to_owned(), " Enter / Esc close "), - }; - - let title = vec![Span::styled( - format!(" {} ", heading), - accent.add_modifier(Modifier::BOLD), - )]; - let block = Block::bordered() - .border_type(BorderType::Rounded) - .border_style(accent) - .style(Style::default().bg(theme::EDITOR_BG).fg(theme::USER_FG)) - .title(Line::from(title).style(accent)) - .title_alignment(Alignment::Center) - .title_bottom(Line::from(footer).style(muted)); - - let inner = block.inner(popup); - frame.render_widget(block, popup); - - match state { - OverlayState::Editing => { - let max_label = (0..FIELD_COUNT) - .map(|i| FormFields::label(i).len()) - .max() - .unwrap_or(0); - for i in 0..FIELD_COUNT { - let y = inner.y + i as u16 * 2; - if y + 2 > inner.bottom() { - break; - } - let is_focused = i == focused; - let label_style = if is_focused { - accent.add_modifier(Modifier::BOLD) - } else { - muted - }; - let line_style = if is_focused { - Style::default().bg(theme::SELECTION_BG) - } else { - Style::default() - }; - - // Value (API is a fixed read-only label so the user - // knows local models use the chat-completions API). - let value = if i == API { - "Chat Completions" - } else { - fields.field(i) - }; - let value_style = if is_focused { - Style::default() - .fg(theme::SELECTION_FG) - .bg(theme::SELECTION_BG) - } else { - Style::default().fg(theme::EDITOR_FG) - }; - 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) - }; - - 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), - ]; - // 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), - ); - let hint = if i == MODEL { - detect_hint(detect, fields.model_id.trim().is_empty()) - } else { - String::new() - }; - frame.render_widget( - Paragraph::new(Span::styled(hint, muted)), - Rect::new(inner.x, y + 1, inner.width, 1), - ); - } - } - OverlayState::Saving => { - frame.render_widget(Paragraph::new(" Saving…").style(accent), inner); - } - OverlayState::Success => { - let msg = if edit_mode { - "Model updated." - } else { - "Model added." - }; - frame.render_widget(Paragraph::new(format!(" {msg}")).style(accent), inner); - } - OverlayState::Error(err) => { - frame.render_widget( - Paragraph::new(format!(" {err}")).style(Style::default().fg(Color::Red)), - inner, - ); - } - } -} - -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 or ◀ ▶ 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::*; - use ratatui::{Terminal, backend::TestBackend}; - - 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(), - api_key: "sk-abc".to_owned(), - }; - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| { - render_local_model_overlay( - frame, - frame.area(), - &fields, - 0, - &OverlayState::Editing, - false, - detect, - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - buffer - .content - .iter() - .map(|cell| cell.symbol()) - .collect::() - .chars() - .collect::>() - .chunks(width as usize) - .map(|row| row.iter().collect::()) - .collect() - } - - #[test] - fn form_field_labels_align_to_a_fixed_column() { - 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 == ':') { - colon_cols.push(col); - } - } - assert_eq!( - colon_cols.len(), - FIELD_COUNT, - "expected one colon per field, got {colon_cols:?}" - ); - let first = colon_cols[0]; - assert!( - colon_cols.iter().all(|&c| c == first), - "label colon columns are not aligned: {colon_cols:?}" - ); - } - - #[test] - fn model_field_comes_last() { - let rows = render_grid(80, 18, &Detect::Idle); - 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 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 or ◀ ▶ 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 { - model_id: String::new(), - url: "http://localhost:11434".to_owned(), - api_key: String::new(), - }; - assert!(fields.to_entry().is_none()); - } - - #[test] - fn to_entry_rejects_empty_url() { - let fields = FormFields { - model_id: "llama3".to_owned(), - url: String::new(), - api_key: String::new(), - }; - assert!(fields.to_entry().is_none()); - } - - #[test] - fn to_entry_accepts_valid_fields() { - let fields = FormFields { - model_id: "llama3".to_owned(), - url: "http://localhost:11434".to_owned(), - api_key: "sk-abc".to_owned(), - }; - let entry = fields.to_entry().unwrap(); - assert_eq!(entry.model_id, "llama3"); - assert_eq!(entry.url, "http://localhost:11434"); - assert_eq!(entry.api, LocalApi::ChatCompletions); - assert_eq!(entry.api_key, Some("sk-abc".to_owned())); - } - - #[test] - fn to_entry_strips_whitespace() { - let fields = FormFields { - model_id: " llama3 ".to_owned(), - url: " http://localhost:11434 ".to_owned(), - api_key: String::new(), - }; - let entry = fields.to_entry().unwrap(); - assert_eq!(entry.model_id, "llama3"); - assert_eq!(entry.url, "http://localhost:11434"); - assert_eq!(entry.api_key, None); - } -} diff --git a/crates/alan/src/local_model_store.rs b/crates/alan/src/local_model_store.rs deleted file mode 100644 index 08868c8..0000000 --- a/crates/alan/src/local_model_store.rs +++ /dev/null @@ -1,101 +0,0 @@ -use crate::core::store::JsonStore; -use anyhow::Result; -use providers::{LocalModelEntry, LocalModelStore, ProviderError}; -use std::path::PathBuf; - -/// `LocalModelStore` backed by `JsonStore` with the same -/// `.lock` sidecar protection that `JsonStore` already provides. -pub struct JsonLocalModelStore { - store: JsonStore, -} - -impl JsonLocalModelStore { - pub fn new(path: PathBuf) -> Self { - Self { - store: JsonStore::new(path), - } - } -} - -#[async_trait::async_trait] -impl LocalModelStore for JsonLocalModelStore { - async fn load(&self) -> Result, ProviderError> { - let value = self - .store - .get("local_models") - .await - .map_err(|e| ProviderError::Fetch(e.to_string()))?; - match value { - Some(serde_json::Value::Array(arr)) => { - serde_json::from_value::>(serde_json::Value::Array(arr)) - .map_err(|e| ProviderError::Fetch(e.to_string())) - } - Some(_) | None => Ok(Vec::new()), - } - } - - async fn save(&self, entries: &[LocalModelEntry]) -> Result<(), ProviderError> { - let value = - serde_json::to_value(entries).map_err(|e| ProviderError::Fetch(e.to_string()))?; - self.store - .set("local_models", value) - .await - .map_err(|e| ProviderError::Fetch(e.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::store::JsonStore; - use providers::LocalApi; - use serde_json::json; - use std::env::temp_dir; - - fn temp_path(name: &str) -> PathBuf { - let mut path = temp_dir(); - path.push(format!( - "alan-local-store-test-{}-{name}.json", - std::process::id() - )); - let _ = std::fs::remove_file(&path); - path - } - - #[tokio::test] - async fn load_missing_file_returns_empty() { - let store = JsonLocalModelStore::new(temp_path("missing")); - assert!(store.load().await.unwrap().is_empty()); - } - - #[tokio::test] - async fn save_then_load_roundtrips() { - let store = JsonLocalModelStore::new(temp_path("roundtrip")); - let entries = vec![LocalModelEntry { - model_id: "llama3".into(), - url: "http://localhost:11434".into(), - api: LocalApi::ChatCompletions, - api_key: None, - }]; - store.save(&entries).await.unwrap(); - assert_eq!(store.load().await.unwrap(), entries); - } - - #[tokio::test] - async fn load_rejects_malformed_entries() { - let path = temp_path("corrupt"); - // A valid JSON array containing one well-formed and one malformed entry. - JsonStore::new(path.clone()) - .set( - "local_models", - json!([ - {"model_id": "ok", "url": "http://x", "api": "ChatCompletions", "api_key": null}, - {"model_id": "bad"} - ]), - ) - .await - .unwrap(); - let store = JsonLocalModelStore::new(path); - assert!(store.load().await.is_err()); - } -} diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index bfded22..b758605 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -1,7 +1,5 @@ mod core; mod keymap; -mod local_model_overlay; -mod local_model_store; mod logging; mod login_overlay; mod provider_overlay; @@ -19,15 +17,14 @@ use crate::core::settings::{ use crate::core::update::current_version; use crate::core::{ChatController, SlashCommand}; use crate::keymap::AlanKeyMapper; -use crate::local_model_store::JsonLocalModelStore; use crate::logging::init; use crate::root::AlanRoot; use agent::{Agent, SessionManager, ToolPermissionManager, default_tools}; use alan_tui::Runtime; use llm::ReasoningEffort; use providers::{ - DeepSeekProvider, FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider, - Provider, ProviderId, ProviderRegistry, ZaiProvider, bind_model, + DeepSeekProvider, FileCredentialStore, ModelOptions, OpenRouterProvider, Provider, ProviderId, + ProviderRegistry, ZaiProvider, bind_model, }; use std::path::PathBuf; use std::sync::Arc; @@ -58,12 +55,8 @@ async fn main() -> anyhow::Result<()> { let credential_store = Arc::new(FileCredentialStore::new(auth_path()?)); let credential_store_for_providers = credential_store.clone() as Arc; - let local_provider = load_local_provider().await?; - let providers = build_providers(&credential_store_for_providers, &local_provider)?; - let registry = Arc::new(ProviderRegistry::with_local_provider( - providers, - Arc::clone(&local_provider), - )); + let providers = build_providers(&credential_store_for_providers)?; + let registry = Arc::new(ProviderRegistry::new(providers)); let custom_providers = Arc::new(CustomProviders::new( CustomProviderStore::new(custom_providers::default_providers_path()?), Arc::clone(®istry), @@ -150,20 +143,8 @@ async fn load_or_create_settings(store: &SettingsStore) -> anyhow::Result anyhow::Result> { - let store = Arc::new(JsonLocalModelStore::new( - core::paths::alan_data_dir()?.join("local_models.json"), - )); - let provider = Arc::new(LocalProvider::new(store)); - if let Err(error) = provider.load().await { - tracing::warn!("failed to load local models: {error}"); - } - Ok(provider) -} - fn build_providers( credential_store: &Arc, - local_provider: &Arc, ) -> anyhow::Result>> { Ok(vec![ Arc::new(ZaiProvider::from_store(Arc::clone(credential_store)).build()?), @@ -171,7 +152,6 @@ fn build_providers( // Arc::new(GoogleProvider::from_store(Arc::clone(credential_store)).build()?), Arc::new(OpenRouterProvider::from_store(Arc::clone(credential_store)).build()?), Arc::new(DeepSeekProvider::from_store(Arc::clone(credential_store)).build()?), - Arc::clone(local_provider) as Arc, ]) } diff --git a/crates/alan/src/views/components/chat_view/mod.rs b/crates/alan/src/views/components/chat_view/mod.rs index 7677436..a41eded 100644 --- a/crates/alan/src/views/components/chat_view/mod.rs +++ b/crates/alan/src/views/components/chat_view/mod.rs @@ -51,12 +51,6 @@ const EDITOR_BOTTOM_PAD: u16 = 1; /// Fixed-rate repaint interval while an agent stream is in flight. const STREAM_REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(32); -/// Action chosen from the local-model picker. -pub(crate) enum LocalPick { - Remove, - Edit, -} - /// Action chosen from the custom-provider picker. #[derive(Clone, Copy)] pub(crate) enum ProviderPick { @@ -262,7 +256,6 @@ impl ChatView { Cmd::Help => controller.push_info(crate::core::SlashCommand::help()), Cmd::New => session::start_new_session(self, cx), Cmd::Models => models::open_models_picker(self, cx), - Cmd::Local => models::handle_local_command(self, &submission.text, cx), Cmd::Provider => models::handle_provider_command(self, &submission.text, cx), Cmd::Profile => profiles::handle_profile_command(self, &submission.text, cx), Cmd::Quit => cx.quit(), diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 6a625e0..7c23144 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -1,5 +1,5 @@ //! Model and provider commands: `/models`, `/model-providers`, -//! `/provider`, `/local`, and the model picker overlays. +//! `/provider`, and the model picker overlays. use crate::core::SlashCommand; use crate::core::server_tools::server_tools; @@ -8,23 +8,18 @@ use crate::root::AlanAction; use alan_tui::TaskError; use alan_tui::context::Context; use providers::{ - Model, ModelInfo, ModelOptions, OpenRouterOptions, Provider, ProviderId, ProviderRegistry, - bind_local_model, bind_model, + Model, ModelInfo, ModelOptions, OpenRouterOptions, ProviderId, ProviderRegistry, bind_model, }; use std::sync::Arc; -use super::{LocalPick, ProviderPick}; +use super::ProviderPick; use crate::views::components::{SearchListEvent, SearchListOverlay}; use super::{ChatView, parse_provider_order, persist_provider_order}; use crate::core::custom_providers::{CustomProviderEntry, ProviderName}; -use crate::local_model_overlay::LocalModelOverlay; use crate::provider_overlay::ProviderOverlay; -/// Provider id reserved for locally-hosted models. -const LOCAL_PROVIDER_ID: &str = "local"; - /// `/models`: open the model picker overlay. pub(crate) fn open_models_picker(view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>) { open_model_picker(view, cx, "Select Model".to_owned(), None); @@ -202,23 +197,6 @@ pub(crate) fn apply_model_provider( ); } -/// `/local`: dispatch local model sub-commands. -pub(crate) fn handle_local_command( - view: &mut ChatView, - text: &str, - cx: &mut Context<'_, ChatView, AlanAction>, -) { - let args = SlashCommand::parse_with_args(text).map(|(_, a)| a.trim().to_owned()); - match args.as_deref() { - Some("add") => open_local_model_overlay(view, cx, None), - Some("remove") => open_local_remove_picker(view, cx), - Some("edit") => open_local_edit_picker(view, cx), - _ => view - .controller - .push_info("usage: /local ".to_owned()), - } -} - /// `/provider`: add, edit or remove a custom provider. pub(crate) fn handle_provider_command( view: &mut ChatView, @@ -367,118 +345,6 @@ fn remove_idle_provider( ); } -/// Open the local model overlay (for adding or editing). -pub(crate) fn open_local_model_overlay( - view: &mut ChatView, - cx: &mut Context<'_, ChatView, AlanAction>, - edit_entry: Option, -) { - let Some(local_provider) = view.providers.local() else { - view.controller - .push_info("Local provider not available".to_owned()); - return; - }; - cx.open_overlay(LocalModelOverlay::new(local_provider, edit_entry)); -} - -/// Open a picker to remove a local model. -fn open_local_remove_picker(view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>) { - open_local_picker( - view, - cx, - "Remove Local Model", - "No local models to remove.", - LocalPick::Remove, - ); -} - -/// Open a picker to edit a local model. -fn open_local_edit_picker(view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>) { - open_local_picker( - view, - cx, - "Edit Local Model", - "No local models to edit.", - LocalPick::Edit, - ); -} - -/// Open a `SearchListOverlay` over the local catalog and run `on_pick` with the -/// chosen entry's index. Shared by the remove and edit flows. -fn open_local_picker( - view: &mut ChatView, - cx: &mut Context<'_, ChatView, AlanAction>, - title: &str, - empty_message: &str, - pick: LocalPick, -) { - let Some(local) = view.providers.local() else { - view.controller.push_info(empty_message.to_owned()); - return; - }; - let models: Vec = local - .models() - .iter() - .map(|m| format!("local — {}", m.name)) - .collect(); - if models.is_empty() { - view.controller.push_info(empty_message.to_owned()); - return; - } - let picker = cx.open_overlay(SearchListOverlay::new(title, models)); - let local = local.clone(); - cx.subscribe_once::( - picker, - move |event, _view, _picker, cx| { - let SearchListEvent::Chosen(index) = event else { - return; - }; - let Some(model_info) = local.models().into_iter().nth(*index) else { - return; - }; - match &pick { - LocalPick::Remove => { - let local = Arc::clone(&local); - let model_id = model_info.id.clone(); - let display_id = model_id.clone(); - cx.spawn( - async move { - local - .remove_model(&model_id) - .await - .map_err(|e| TaskError(e.to_string().into()))?; - Ok::<(), TaskError>(()) - }, - move |result, view, _cx| match result { - Ok(()) => { - view.controller - .push_info(format!("Removed local model: {display_id}")); - } - Err(e) => view.controller.push_info(format!("Failed to remove: {e}")), - }, - ); - } - LocalPick::Edit => { - let local = Arc::clone(&local); - cx.spawn( - async move { - local.find_entry(&model_info.id).ok_or_else(|| { - TaskError( - format!("Local model not found: {}", model_info.id).into(), - ) - }) - }, - move |result, view, cx| match result { - Ok(entry) => open_local_model_overlay(view, cx, Some(entry)), - Err(e) => view.controller.push_info(e.to_string()), - }, - ); - } - } - }, - ); -} - pub(crate) async fn fetch_all_models(providers: &ProviderRegistry) { for provider in providers.providers() { if let Err(e) = provider.fetch_models().await { @@ -527,23 +393,12 @@ pub(crate) fn server_tools_for_provider( }) } -/// Bind a catalog entry to a live model. Local entries bind through the -/// registry's local provider; everything else through the named provider. +/// Bind a catalog entry to a live model through its provider. pub(crate) fn bind_model_info( providers: &ProviderRegistry, model_info: &ModelInfo, options: ModelOptions, ) -> Result { - if model_info.provider.0 == LOCAL_PROVIDER_ID { - let local = providers - .local() - .ok_or_else(|| TaskError("local provider not available".into()))?; - let entry = local - .find_entry(&model_info.id) - .ok_or_else(|| TaskError(format!("local model not found: {}", model_info.id).into()))?; - return bind_local_model(&entry, options).map_err(|e| TaskError(e.into())); - } - let provider = providers .get(&model_info.provider) .ok_or_else(|| TaskError(format!("provider not found: {}", model_info.provider).into()))?; @@ -562,7 +417,7 @@ pub(crate) async fn persist_model(model_id: &str, provider_id: &ProviderId) -> a #[cfg(test)] mod tests { use super::*; - use providers::{CustomProvider, InMemoryCredentialStore}; + use providers::{CustomProvider, InMemoryCredentialStore, Provider}; fn provider(name: &str, model: &str) -> Arc { Arc::new(CustomProvider::new( diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index 19c8eed..7f57709 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, SearchListEvent, SearchListOverlay}; +pub(crate) use components::{ChatView, Header, LoginRequested}; diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index 2185e0a..e855456 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -4,11 +4,9 @@ mod credentials; mod custom; mod deepseek; mod google; -mod local; mod model; mod openrouter; mod provider; -mod store; mod zai; pub use custom::{CustomProvider, fetch_catalog}; @@ -23,9 +21,7 @@ pub use credentials::{ FileCredentialStore, InMemoryCredentialStore, SharedCredentialStore, }; pub use google::{GoogleBuilder, GoogleProvider}; -pub use local::{LocalApi, LocalModelEntry, LocalProvider, bind_local_model, list_local_models}; pub use model::{Model, ModelError, ModelOptions}; pub use openrouter::{OpenRouterBuilder, OpenRouterProvider, Options as OpenRouterOptions}; pub use provider::{Provider, ProviderError, ProviderRegistry, bind_model}; -pub use store::LocalModelStore; pub use zai::{ZaiBuilder, ZaiProvider}; diff --git a/crates/providers/src/local.rs b/crates/providers/src/local.rs deleted file mode 100644 index 989992a..0000000 --- a/crates/providers/src/local.rs +++ /dev/null @@ -1,594 +0,0 @@ -use crate::store::LocalModelStore; -use crate::{ - ApiId, ApiKeyAuth, AuthResolver, ModelInfo, ModelOptions, NoAuth, Provider, ProviderError, - ProviderId, ServerToolInfo, -}; -use async_trait::async_trait; -use llm::{ChatCompletionsApi, HttpClient, LlmApi}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::{Arc, LazyLock, RwLock}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum LocalApi { - ChatCompletions, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct LocalModelEntry { - pub model_id: String, - pub url: String, - pub api: LocalApi, - pub api_key: Option, -} - -pub struct LocalProvider { - store: Arc, - entries: RwLock>, - models: RwLock>, -} - -impl LocalProvider { - pub fn new(store: Arc) -> Self { - Self { - store, - entries: RwLock::new(Vec::new()), - models: RwLock::new(Vec::new()), - } - } - - pub async fn load(&self) -> Result<(), ProviderError> { - let entries = self.store.load().await?; - let models = Self::rebuild(&entries); - *self.entries.write().unwrap_or_else(|e| e.into_inner()) = entries; - *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; - Ok(()) - } - - pub async fn add_model(&self, entry: LocalModelEntry) -> Result<(), ProviderError> { - { - let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner()); - if entries - .iter() - .any(|existing| existing.model_id == entry.model_id) - { - return Err(ProviderError::Fetch(format!( - "local model already exists: {}", - entry.model_id - ))); - } - entries.push(entry); - let models = Self::rebuild(&entries); - *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; - } - self.persist().await - } - - pub async fn remove_model(&self, model_id: &str) -> Result { - let removed; - { - let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner()); - let len_before = entries.len(); - entries.retain(|e| e.model_id != model_id); - removed = entries.len() < len_before; - let models = Self::rebuild(&entries); - *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; - } - if removed { - self.persist().await?; - } - Ok(removed) - } - - pub async fn update_model(&self, entry: LocalModelEntry) -> Result<(), ProviderError> { - { - let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner()); - let Some(existing) = entries.iter_mut().find(|e| e.model_id == entry.model_id) else { - return Err(ProviderError::ModelNotFound(entry.model_id)); - }; - *existing = entry; - let models = Self::rebuild(&entries); - *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; - } - self.persist().await - } - - pub fn find_entry(&self, model_id: &str) -> Option { - self.entries - .read() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .find(|e| e.model_id == model_id) - .cloned() - } - - fn rebuild(entries: &[LocalModelEntry]) -> Vec { - entries - .iter() - .map(|e| ModelInfo { - provider: ProviderId::new("local"), - id: e.model_id.clone(), - name: e.model_id.clone(), - pricing: None, - context_length: None, - }) - .collect() - } - - async fn persist(&self) -> Result<(), ProviderError> { - let entries = self - .entries - .read() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - self.store.save(&entries).await - } -} - -#[async_trait] -impl Provider for LocalProvider { - fn id(&self) -> ProviderId { - ProviderId::new("local") - } - - fn models(&self) -> Vec { - self.models - .read() - .unwrap_or_else(|e| e.into_inner()) - .clone() - } - - fn server_tools(&self) -> &[ServerToolInfo] { - &[] - } - - fn auth_methods(&self) -> Vec { - vec![] - } - - fn apis(&self) -> &HashMap> { - // Local models bind per-entry via `bind_local_model`, which reads the - // URL from the chosen `LocalModelEntry`. This provider-level entry is a - // placeholder so the catalog advertises chat-completions support. - static APIS: LazyLock>> = LazyLock::new(|| { - HashMap::from([( - ApiId::ChatCompletions, - Arc::new(ChatCompletionsApi::new("", Arc::new(HttpClient::new()))) - as Arc, - )]) - }); - &APIS - } - - fn auth(&self) -> Arc { - Arc::new(NoAuth) - } - - async fn validate_auth( - &self, - _auth_result: &crate::auth::AuthResult, - ) -> Result<(), crate::auth::AuthError> { - Ok(()) - } - - async fn fetch_models(&self) -> Result<(), ProviderError> { - self.load().await - } -} - -/// Bind a [`crate::Model`] to a local model entry, using the entry's URL and -/// optional API key. Local models are chat-completions compatible. -pub fn bind_local_model( - entry: &LocalModelEntry, - options: ModelOptions, -) -> Result { - let api: Arc = Arc::new(ChatCompletionsApi::new( - &entry.url, - Arc::new(HttpClient::new()), - )); - let auth: Arc = match entry.api_key.as_deref() { - Some(key) if !key.is_empty() => Arc::new(ApiKeyAuth::new(key)), - _ => Arc::new(NoAuth), - }; - let info = ModelInfo { - provider: ProviderId::new("local"), - id: entry.model_id.clone(), - name: entry.model_id.clone(), - pricing: None, - context_length: None, - }; - 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::*; - use crate::store::LocalModelStore; - - fn make_entry(model_id: &str) -> LocalModelEntry { - LocalModelEntry { - model_id: model_id.into(), - url: format!("http://localhost:8080/{model_id}"), - api: LocalApi::ChatCompletions, - api_key: None, - } - } - - #[derive(Default)] - struct MockStore { - entries: RwLock>, - } - - impl MockStore { - fn shared() -> Arc { - Arc::new(Self::default()) - } - } - - #[async_trait::async_trait] - impl LocalModelStore for MockStore { - async fn load(&self) -> Result, ProviderError> { - Ok(self - .entries - .read() - .unwrap_or_else(|e| e.into_inner()) - .clone()) - } - - async fn save(&self, entries: &[LocalModelEntry]) -> Result<(), ProviderError> { - *self.entries.write().unwrap_or_else(|e| e.into_inner()) = entries.to_vec(); - Ok(()) - } - } - - #[tokio::test] - async fn load_missing_file_returns_empty() { - let store = MockStore::shared(); - let provider = LocalProvider::new(store); - provider.load().await.unwrap(); - assert!(provider.models().is_empty()); - } - - #[tokio::test] - async fn add_model_appears_in_catalog() { - let store = MockStore::shared(); - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("llama3")).await.unwrap(); - let models = provider.models(); - assert_eq!(models.len(), 1); - assert_eq!(models[0].id, "llama3"); - assert_eq!(models[0].provider, ProviderId::new("local")); - // Verify persistence via store - let persisted = store.load().await.unwrap(); - assert_eq!(persisted.len(), 1); - } - - #[tokio::test] - async fn add_multiple_models() { - let store = MockStore::shared(); - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("a")).await.unwrap(); - provider.add_model(make_entry("b")).await.unwrap(); - assert_eq!(provider.models().len(), 2); - let persisted = store.load().await.unwrap(); - assert_eq!(persisted.len(), 2); - } - - #[tokio::test] - async fn remove_model() { - let store = MockStore::shared(); - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("a")).await.unwrap(); - provider.add_model(make_entry("b")).await.unwrap(); - let removed = provider.remove_model("a").await.unwrap(); - assert!(removed); - assert_eq!(provider.models().len(), 1); - assert_eq!(provider.models()[0].id, "b"); - let persisted = store.load().await.unwrap(); - assert_eq!(persisted.len(), 1); - assert_eq!(persisted[0].model_id, "b"); - } - - #[tokio::test] - async fn remove_nonexistent_returns_false() { - let store = MockStore::shared(); - let provider = LocalProvider::new(store); - assert!(!provider.remove_model("nope").await.unwrap()); - } - - #[tokio::test] - async fn update_model() { - let store = MockStore::shared(); - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("a")).await.unwrap(); - let updated = LocalModelEntry { - model_id: "a".into(), - url: "http://new-url:8080/v1".into(), - api: LocalApi::ChatCompletions, - api_key: Some("key".into()), - }; - provider.update_model(updated).await.unwrap(); - let found = provider.find_entry("a").unwrap(); - assert_eq!(found.url, "http://new-url:8080/v1"); - let persisted = store.load().await.unwrap(); - assert_eq!(persisted[0].url, "http://new-url:8080/v1"); - } - - #[tokio::test] - async fn find_entry_returns_none_for_missing() { - let store = MockStore::shared(); - let provider = LocalProvider::new(store); - assert!(provider.find_entry("nope").is_none()); - } - - #[tokio::test] - async fn find_entry_returns_correct_entry() { - let store = MockStore::shared(); - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("x")).await.unwrap(); - let entry = provider.find_entry("x").unwrap(); - assert_eq!(entry.model_id, "x"); - } - - #[tokio::test] - async fn persists_to_disk_and_reloads() { - let store = MockStore::shared(); - { - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("a")).await.unwrap(); - } - let provider = LocalProvider::new(Arc::clone(&store)); - provider.load().await.unwrap(); - assert_eq!(provider.models().len(), 1); - assert_eq!(provider.models()[0].id, "a"); - } - - #[tokio::test] - async fn remove_persists_to_disk() { - let store = MockStore::shared(); - { - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("a")).await.unwrap(); - provider.add_model(make_entry("b")).await.unwrap(); - provider.remove_model("a").await.unwrap(); - } - let provider = LocalProvider::new(Arc::clone(&store)); - provider.load().await.unwrap(); - assert_eq!(provider.models().len(), 1); - assert_eq!(provider.models()[0].id, "b"); - } - - #[tokio::test] - async fn update_persists_to_disk() { - let store = MockStore::shared(); - { - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("a")).await.unwrap(); - let updated = LocalModelEntry { - model_id: "a".into(), - url: "http://updated:9090".into(), - api: LocalApi::ChatCompletions, - api_key: None, - }; - provider.update_model(updated).await.unwrap(); - } - let provider = LocalProvider::new(Arc::clone(&store)); - provider.load().await.unwrap(); - let entry = provider.find_entry("a").unwrap(); - assert_eq!(entry.url, "http://updated:9090"); - assert_eq!(entry.api, LocalApi::ChatCompletions); - } - - #[tokio::test] - async fn fetch_models_populates_catalog() { - let store = MockStore::shared(); - { - let provider = LocalProvider::new(Arc::clone(&store)); - provider.add_model(make_entry("x")).await.unwrap(); - } - let provider = LocalProvider::new(Arc::clone(&store)); - provider.fetch_models().await.unwrap(); - assert_eq!(provider.models().len(), 1); - assert_eq!(provider.models()[0].id, "x"); - } - - #[tokio::test] - async fn bind_chat_completions() { - let entry = LocalModelEntry { - model_id: "llama3".into(), - url: "http://localhost:11434/v1".into(), - api: LocalApi::ChatCompletions, - api_key: None, - }; - let model = bind_local_model(&entry, ModelOptions::default()).unwrap(); - assert_eq!(model.info().provider, ProviderId::new("local")); - assert_eq!(model.info().id, "llama3"); - } - - #[tokio::test] - async fn bind_uses_entry_url() { - let entry = LocalModelEntry { - model_id: "gemini-local".into(), - url: "http://localhost:8080".into(), - api: LocalApi::ChatCompletions, - api_key: None, - }; - let model = bind_local_model(&entry, ModelOptions::default()).unwrap(); - assert_eq!(model.info().id, "gemini-local"); - assert_eq!(model.info().provider, ProviderId::new("local")); - } - - #[tokio::test] - async fn provider_id_is_local() { - let store = MockStore::shared(); - let provider = LocalProvider::new(store); - 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(); - let provider = LocalProvider::new(store); - assert!(provider.models().is_empty()); - } -} diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 5ae4e0e..436ba82 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -1,7 +1,6 @@ use crate::Model; use crate::auth::AuthResolver; use crate::catalog::{ApiId, ModelInfo, ProviderId, ServerToolInfo}; -use crate::local::LocalProvider; use crate::model::ModelOptions; use async_trait::async_trait; use llm::LlmApi; @@ -52,24 +51,12 @@ pub trait Provider: Send + Sync { pub struct ProviderRegistry { providers: RwLock>>, - local_provider: Option>, } impl ProviderRegistry { pub fn new(providers: impl IntoIterator>) -> Self { Self { providers: RwLock::new(providers.into_iter().collect()), - local_provider: None, - } - } - - pub fn with_local_provider( - providers: impl IntoIterator>, - local_provider: Arc, - ) -> Self { - Self { - providers: RwLock::new(providers.into_iter().collect()), - local_provider: Some(local_provider), } } @@ -108,11 +95,6 @@ impl ProviderRegistry { providers.retain(|provider| provider.id() != *id); providers.len() < before } - - /// Return an `Arc` clone of the local provider, if registered. - pub fn local(&self) -> Option> { - self.local_provider.clone() - } } /// Construct a [`Model`] bound to `model_id` from the provider's catalog, diff --git a/crates/providers/src/store.rs b/crates/providers/src/store.rs deleted file mode 100644 index 1799356..0000000 --- a/crates/providers/src/store.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::{LocalModelEntry, ProviderError}; - -/// Persists local-model entries to disk. -/// -/// `LocalProvider` holds a `Arc` and delegates all -/// file I/O here. The concrete implementation lives in the `alan` crate -/// (which owns the `fs2` file-lock pattern and `JsonStore`). -#[async_trait::async_trait] -pub trait LocalModelStore: Send + Sync { - /// Load entries from disk. Missing file => empty vec. - async fn load(&self) -> Result, ProviderError>; - - /// Overwrite the entire entry list on disk. - async fn save(&self, entries: &[LocalModelEntry]) -> Result<(), ProviderError>; -} From 3c3933c9b218e27b294451b45f8b3ed9061133cf Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 18:04:21 +0530 Subject: [PATCH 07/36] Document /provider --- AGENTS.md | 11 +++++++---- CHANGELOG.md | 8 ++++++++ README.md | 18 +++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6cc08a..319b353 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,9 +58,12 @@ settings for future launches; manual settings changes clear its active-profile marker. Profiles are stored separately in `/profiles.json`. See `core::paths` for how the data directory is resolved. -`build_providers` in `crates/alan/src/main.rs` lists the registered providers. -The `providers` crate also has a Google provider that is not registered. Sign in -with `/login`, or set the provider's API key variable: +`build_providers` in `crates/alan/src/main.rs` lists the built-in providers. +The `providers` crate also has a Google provider that is not registered. +Providers added with `/provider add` live in `core::custom_providers`: name and +URL in `/providers.json`, key in `auth.json`, registered as +`providers::CustomProvider`. Sign in with `/login`, or set the provider's API +key variable: ```bash OPENROUTER_API_KEY=... cargo run -p alan @@ -143,7 +146,7 @@ auto-includes it in release archives. `ChatView` (`views/components/chat_view/`) owns the chat session: the `ChatController` and the agent stream. `PromptEditor` owns input. Overlays -such as login and local models live beside `root.rs`. +such as login and providers live beside `root.rs`. Keep UI simple. Avoid borders, unnecessary widgets, and premature abstraction. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9050e22..05e96bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `/provider add`, `/provider edit` and `/provider remove` to use any Chat Completions server by name. Alan checks the server's `/models` before saving, lists all of its models in `/models`, and keeps the key in `auth.json` so `/login` can change it. + +### Fixed + +- When the saved provider no longer exists, Alan starts on the default model and says so, instead of failing to start. + ## 0.1.5 - 2026-10-07 ### Added diff --git a/README.md b/README.md index eeae7fe..b9d093d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A minimal coding agent in your terminal. - [Providers](#providers) - [Usage](#usage) - [Permissions](#permissions) +- [Custom providers](#custom-providers) - [Skills](#skills) - [Configuration and data locations](#configuration-and-data-locations) - [Building from source](#building-from-source) @@ -26,6 +27,7 @@ A minimal coding agent in your terminal. - **Steering** — queue a message mid-run and the agent picks it up without waiting for the response to finish. - **Plan and review modes** — cycle through them with `Shift+Tab` when you want the agent to think before it acts. - **Skills** — type `#name` in the prompt to attach a skill's full instructions to that message. +- **Custom providers** — add any Chat Completions server, hosted or on your machine, with `/provider add`; all of its models appear in `/models`. ## Installation @@ -73,6 +75,8 @@ Alan currently supports: Use the `/login` command to sign in to any of the available providers. +Any other Chat Completions server can be added with `/provider add` (see [Custom providers](#custom-providers)). + For OpenRouter, you can pin a custom provider for the selected model with `/model-providers`, followed by the provider name (no quotes): - `/model-providers deepseek` @@ -86,6 +90,9 @@ For OpenRouter, you can pin a custom provider for the selected model with `/mode | `/profile` | Open the profile picker to switch profiles (`Ctrl+P`, or `Cmd+Option+P` where the terminal passes it through) | | `/profile save ` | Save current model/provider, reasoning, and web settings as a new profile | | `/profile delete` | Pick a saved profile to delete | +| `/provider add` | Add a Chat Completions provider: name, URL and optional API key | +| `/provider edit` | Change a provider's URL or API key | +| `/provider remove` | Remove a provider and its API key | | `/plan` | Toggle plan mode (also `Shift+Tab`) | | `/review` | Toggle review mode (also `Shift+Tab`) | | `/normal` | Turn off plan and review mode | @@ -119,6 +126,14 @@ Alan starts in strict policy and remembers the policy you pick across runs. Swit - `/tool-free` - `/tool-strict` +## Custom providers + +Any server that speaks the Chat Completions API can be a provider: hosted APIs such as Groq, xAI or Mistral, or a model server on your machine such as Ollama or LM Studio. + +`/provider add` asks for a name, the base URL (the part before `/chat/completions`, e.g. `https://api.groq.com/openai/v1` or `http://localhost:11434/v1`) and an API key if the server needs one. Alan calls `/models` to check the URL and key, and saves the provider only when that call works. Every model the server lists then appears in `/models`, including ones it adds later. + +The key is stored in `auth.json` with the other providers' keys, so `/login` can change it too. `/provider edit` changes the URL or key; the name is fixed. `/provider remove` deletes the provider and its key. If one of its models is in use, Alan first asks which model to switch to. + ## Configuration and data locations Alan stores everything under `~/.alan/` by default: @@ -126,6 +141,7 @@ Alan stores everything under `~/.alan/` by default: - `settings.json` — your current settings and active-profile marker. - `profiles.json` — saved named model/reasoning/web settings profiles. - `auth.json` — provider credentials saved by `/login`. +- `providers.json` — providers added with `/provider add`. - `projects/` — per-project permission grants. - `sessions/` — conversation history (append-only JSONL, one file per session). - `skills/` — personal skills, available in every project. @@ -140,7 +156,7 @@ Settings variables override `settings.json` for that run without changing it. St | Variable | Effect | | --- | --- | | `ALAN_HOME` | Parent of the data directory: `ALAN_HOME=/tmp/x` puts everything in `/tmp/x/.alan`. | -| `ALAN_PROVIDER` | Provider id: `openrouter`, `deepseek` or `zai`. | +| `ALAN_PROVIDER` | Provider id: `openrouter`, `deepseek`, `zai`, or a name added with `/provider add`. | | `ALAN_MODEL` | Model id for that provider, e.g. `ALAN_MODEL=openai/gpt-4o-mini alan`. | | `ALAN_REASONING_EFFORT` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. | | `ALAN_OPENROUTER_WEB_FETCH` | Turn OpenRouter's web fetch tool on or off: `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`. | From cade7922c6bedf6cd934e399c3b84776a215fb79 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 11 Oct 2026 18:16:19 +0530 Subject: [PATCH 08/36] Apply /provider edit to the model already in use --- crates/alan/src/core/custom_providers.rs | 85 ++++++++++++++++++++++-- crates/providers/src/custom.rs | 56 +++++++++++++--- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index f831195..01cc046 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -8,9 +8,10 @@ use providers::{ fetch_catalog, }; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::core::paths; use crate::core::store::JsonStore; @@ -24,7 +25,7 @@ const RESERVED_NAMES: [&str; 4] = ["openrouter", "deepseek", "zai", "google"]; /// A custom provider's name, and its id in `/models`, `auth.json`, /// `settings.json` and `ALAN_PROVIDER`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct ProviderName(String); @@ -141,6 +142,8 @@ pub struct CustomProviders { store: CustomProviderStore, registry: Arc, credentials: Arc, + /// The registered instances, typed, so an edit can update one in place. + registered: Mutex>>, } impl CustomProviders { @@ -153,6 +156,7 @@ impl CustomProviders { store, registry, credentials, + registered: Mutex::new(HashMap::new()), } } @@ -208,6 +212,10 @@ impl CustomProviders { ); self.store.remove(name).await?; self.credentials.delete(&name.id()).await?; + self.registered + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(name); self.registry.remove(&name.id()); Ok(()) } @@ -233,12 +241,21 @@ impl CustomProviders { } fn register(&self, entry: &CustomProviderEntry, models: Vec) { - self.registry.insert(Arc::new(CustomProvider::new( + let mut registered = self.registered.lock().unwrap_or_else(|e| e.into_inner()); + // A bound model keeps its provider's API client: replacing the + // provider on an edit would leave the running model on the old URL. + if let Some(provider) = registered.get(&entry.name) { + provider.set_base_url(entry.url.clone()); + return; + } + let provider = Arc::new(CustomProvider::new( entry.name.id(), entry.url.clone(), Arc::clone(&self.credentials), models, - ))); + )); + registered.insert(entry.name.clone(), Arc::clone(&provider)); + self.registry.insert(provider); } } @@ -304,6 +321,32 @@ mod tests { format!("http://{address}/v1/") } + // Replies `OK_TWO_MODELS` to `count` connections, hands back each request. + async fn serve_many(count: usize) -> (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 requests = Vec::new(); + for _ in 0..count { + 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(OK_TWO_MODELS.as_bytes()).await.unwrap(); + requests.push(String::from_utf8(request).unwrap()); + } + requests + }); + (format!("http://{address}/v1/"), server) + } + #[test] fn provider_name_accepts_lowercase_digits_and_dashes() { assert_eq!(name(" my-groq2 ").to_string(), "my-groq2"); @@ -462,6 +505,40 @@ mod tests { assert!(providers.key(&name("grok")).await.unwrap().is_none()); } + #[tokio::test] + async fn update_moves_models_already_bound_to_the_new_url() { + let (providers, registry, _credentials) = service("edit-bound"); + let old_url = serve_once(OK_TWO_MODELS).await; + providers.add(name("grok"), &old_url, None).await.unwrap(); + let provider = registry.get(&name("grok").id()).unwrap(); + let model = providers::bind_model(provider.as_ref(), "m1", Default::default()).unwrap(); + + // The new server answers the edit's check, then the chat request. + let (new_url, server) = serve_many(2).await; + providers + .update(name("grok"), &new_url, None) + .await + .unwrap(); + let messages = [llm::Message::user("hi")]; + let _ = model + .stream(llm::CompletionInput { + messages: &messages, + tools: &[], + options: &Default::default(), + extensions: &Default::default(), + }) + .await; + + let requests = tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .expect("the chat request never reached the new URL") + .unwrap(); + assert!( + requests[1].starts_with("POST /v1/chat/completions"), + "{requests:?}" + ); + } + #[tokio::test] async fn remove_deletes_entry_key_and_provider() { let (providers, registry, _credentials) = service("remove-all"); diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index 7425622..a78da82 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -6,7 +6,10 @@ use crate::{ Provider, ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; -use llm::{ChatCompletionsApi, Credential as RequestCredential, HttpClient, LlmApi}; +use llm::{ + ChatCompletionsApi, Credential as RequestCredential, HttpClient, LlmApi, LlmError, LlmRequest, + LlmStream, +}; use serde::Deserialize; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -22,7 +25,9 @@ const CONTEXT_FIELDS: [&str; 4] = [ pub struct CustomProvider { id: ProviderId, - base_url: String, + /// Shared with the API client, so models already bound to this provider + /// follow [`set_base_url`](Self::set_base_url), as they follow key changes. + base_url: Arc>, models: RwLock>, apis: HashMap>, auth: Arc, @@ -37,11 +42,11 @@ impl CustomProvider { credentials: Arc, models: Vec, ) -> Self { - let base_url = base_url.into(); - let api: Arc = Arc::new(ChatCompletionsApi::new( - base_url.clone(), - Arc::new(HttpClient::new()), - )); + let base_url = Arc::new(RwLock::new(base_url.into())); + let api: Arc = Arc::new(LiveUrlApi { + base_url: Arc::clone(&base_url), + http: Arc::new(HttpClient::new()), + }); Self { auth: Arc::new(StoredKeyAuth { provider: id.clone(), @@ -53,6 +58,39 @@ impl CustomProvider { id, } } + + /// Point the provider, and every model already bound to it, at `base_url`. + pub fn set_base_url(&self, base_url: impl Into) { + *self.base_url.write().unwrap_or_else(|e| e.into_inner()) = base_url.into(); + } + + fn base_url(&self) -> String { + self.base_url + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } +} + +/// Chat Completions against the provider's URL as it is when each request +/// starts. +struct LiveUrlApi { + base_url: Arc>, + http: Arc, +} + +#[async_trait] +impl LlmApi for LiveUrlApi { + async fn stream(&self, request: LlmRequest<'_>) -> Result { + let base_url = self + .base_url + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + ChatCompletionsApi::new(base_url, Arc::clone(&self.http)) + .stream(request) + .await + } } #[async_trait] @@ -86,7 +124,7 @@ impl Provider for CustomProvider { async fn validate_auth(&self, auth_result: &AuthResult) -> Result<(), AuthError> { match auth_result { - AuthResult::ApiKey(key) => fetch_catalog(&self.id, &self.base_url, Some(key)) + AuthResult::ApiKey(key) => fetch_catalog(&self.id, &self.base_url(), Some(key)) .await .map(drop) .map_err(|error| AuthError::Validation(error.to_string())), @@ -103,7 +141,7 @@ impl Provider for CustomProvider { RequestCredential::ApiKey(key) => Some(key), _ => None, }; - let models = fetch_catalog(&self.id, &self.base_url, key.as_deref()).await?; + let models = fetch_catalog(&self.id, &self.base_url(), key.as_deref()).await?; *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; Ok(()) } From 6a602ee9824201f49c7494dc678fe90ba5a90ad3 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:14:25 +0530 Subject: [PATCH 09/36] Stop the docs saying every provider needs an API key or env variable --- AGENTS.md | 11 ++++++----- README.md | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 319b353..f3a1d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,16 +59,17 @@ marker. Profiles are stored separately in `/profiles.json`. See `core::paths` for how the data directory is resolved. `build_providers` in `crates/alan/src/main.rs` lists the built-in providers. -The `providers` crate also has a Google provider that is not registered. -Providers added with `/provider add` live in `core::custom_providers`: name and -URL in `/providers.json`, key in `auth.json`, registered as -`providers::CustomProvider`. Sign in with `/login`, or set the provider's API -key variable: +The `providers` crate also has a Google provider that is not registered. Sign +in with `/login`, or set the provider's API key variable: ```bash OPENROUTER_API_KEY=... cargo run -p alan ``` +Providers added with `/provider add` live in `core::custom_providers`: name and +URL in `/providers.json`, key only in `auth.json`, registered as +`providers::CustomProvider`. + Optional model override: ```bash diff --git a/README.md b/README.md index b9d093d..7acb41f 100644 --- a/README.md +++ b/README.md @@ -222,4 +222,4 @@ cargo run -p alan ## Requirements - Rust 1.88 or newer (only for building from source) -- An API key for your chosen provider +- An API key for your chosen provider, if it needs one From ef28e2bf04595d63255517ae4e01275d4b43002c Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:14:44 +0530 Subject: [PATCH 10/36] Keep the reason when a server's model list does not parse --- crates/providers/src/custom.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index a78da82..83bcb72 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -206,8 +206,9 @@ fn parse_catalog(provider: &ProviderId, payload: &str) -> Result, data: Vec, } - let listing: Listing = serde_json::from_str(payload) - .map_err(|_| ProviderError::Fetch("not an OpenAI-style model list".into()))?; + let listing: Listing = serde_json::from_str(payload).map_err(|error| { + ProviderError::Fetch(format!("not an OpenAI-style model list: {error}")) + })?; let mut models: Vec = listing .data .iter() @@ -340,7 +341,13 @@ mod tests { fn parse_catalog_rejects_empty_and_other_shapes() { assert!(parse_catalog(&id(), r#"{"data":[]}"#).is_err()); assert!(parse_catalog(&id(), r#"{"models":[{"name":"models/gemini"}]}"#).is_err()); - assert!(parse_catalog(&id(), "404").is_err()); + let error = parse_catalog(&id(), "404").unwrap_err(); + assert!( + error + .to_string() + .contains("not an OpenAI-style model list: "), + "{error}" + ); } #[tokio::test] From b45a2b0dd4535e2cde9ae236f61291c501dbd2b4 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:14:57 +0530 Subject: [PATCH 11/36] Fetch a custom provider's models one request at a time, like the built-ins --- crates/providers/src/custom.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index 83bcb72..f4ad7cc 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -31,6 +31,7 @@ pub struct CustomProvider { models: RwLock>, apis: HashMap>, auth: Arc, + fetch_lock: tokio::sync::Mutex<()>, } impl CustomProvider { @@ -54,6 +55,7 @@ impl CustomProvider { }), apis: HashMap::from([(ApiId::ChatCompletions, api)]), models: RwLock::new(models), + fetch_lock: tokio::sync::Mutex::new(()), base_url, id, } @@ -132,6 +134,7 @@ impl Provider for CustomProvider { } async fn fetch_models(&self) -> Result<(), ProviderError> { + let _guard = self.fetch_lock.lock().await; let credential = self .auth .resolve() From 99a448ae82ba5482bd8abdd0259c726ed95c4b18 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:15:21 +0530 Subject: [PATCH 12/36] Let the model picker only switch models, and remove a replaced provider through /provider remove --- .../src/views/components/chat_view/models.rs | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 7c23144..37ad8c9 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -59,7 +59,6 @@ fn open_model_picker( }, ); - let custom_providers = Arc::clone(&view.custom_providers); view.model_subscription = Some(cx.subscribe::( picker, move |event, view, _picker, cx| { @@ -74,8 +73,8 @@ fn open_model_picker( }; let agent = view.controller.agent(); let providers = Arc::clone(&providers); - let custom_providers = Arc::clone(&custom_providers); let replacing = replacing.clone(); + let chosen_provider = model_info.provider.clone(); let _ = cx.spawn( async move { let mut options = agent.model_options().await; @@ -96,34 +95,18 @@ fn open_model_picker( persist_model(&model_info.id, &model_info.provider) .await .map_err(|e| TaskError(e.into()))?; - - // Only after the switch: until then the old provider's - // key is still the one requests go out with. - let removal = match replacing { - Some(replaced) => { - let result = custom_providers - .remove(&replaced, &model_info.provider) - .await; - Some(result.map(|()| replaced).map_err(|e| format!("{e:#}"))) - } - None => None, - }; - Ok((name, max_context, reasoning_effort, removal)) + Ok((name, max_context, reasoning_effort)) }, move |result, view, cx| { match result { - Ok((name, max_context, reasoning_effort, removal)) => { + Ok((name, max_context, reasoning_effort)) => { view.controller.set_max_context(max_context); view.controller.set_reasoning_effort(reasoning_effort); view.controller.apply_model_switch(name); - match removal { - Some(Ok(replaced)) => view - .controller - .push_info(format!("removed provider: {replaced}")), - Some(Err(error)) => view.controller.push_info(format!( - "switched models, but failed to remove the provider: {error}" - )), - None => {} + // Only once the switch succeeded: until then the + // old provider's key is the one requests go out with. + if let Some(replaced) = replacing { + remove_idle_provider(view, cx, replaced, chosen_provider); } } Err(e) => view.controller.apply_model_switch_failed(e.to_string()), From 4789cf51db4f33351c1079d6c1af5ffb5ea26fe9 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:15:58 +0530 Subject: [PATCH 13/36] Keep the startup provider fallback out of main.rs --- crates/alan/src/core/settings.rs | 54 ++++++++++++++++++++++++++- crates/alan/src/main.rs | 64 ++------------------------------ 2 files changed, 57 insertions(+), 61 deletions(-) diff --git a/crates/alan/src/core/settings.rs b/crates/alan/src/core/settings.rs index 0cc4c8f..a12b6b9 100644 --- a/crates/alan/src/core/settings.rs +++ b/crates/alan/src/core/settings.rs @@ -22,9 +22,10 @@ use crate::core::store::JsonStore; const SETTINGS_KEY: &str = "settings"; use anyhow::Context as _; use llm::ReasoningEffort; -use providers::OpenRouterOptions; +use providers::{OpenRouterOptions, Provider, ProviderId, ProviderRegistry}; use std::collections::BTreeMap; use std::path::PathBuf; +use std::sync::Arc; /// Public Settings representing current configuration. All fields are optional so /// that we can distinguish between a field that has not been provided by the @@ -129,6 +130,29 @@ pub fn default_settings_path() -> Result { pub const DEFAULT_MODEL: &str = "openai/gpt-4o-mini"; pub const DEFAULT_PROVIDER: &str = "openrouter"; +/// The provider and model to start on, and a notice when the saved provider +/// no longer exists: removed with `/provider remove`, or a typo in +/// `ALAN_PROVIDER`. +pub fn startup_provider( + registry: &ProviderRegistry, + provider_id: &str, + model_id: String, +) -> Result<(Arc, String, Option)> { + if let Some(provider) = registry.get(&ProviderId::new(provider_id)) { + return Ok((provider, model_id, None)); + } + tracing::warn!( + provider = provider_id, + "saved provider not found, starting on the default model" + ); + let provider = registry + .get(&ProviderId::new(DEFAULT_PROVIDER)) + .with_context(|| format!("Provider not found: {DEFAULT_PROVIDER}"))?; + let notice = + format!("provider {provider_id} not found; using {DEFAULT_PROVIDER} — {DEFAULT_MODEL}"); + Ok((provider, DEFAULT_MODEL.to_owned(), Some(notice))) +} + impl Settings { /// Settings used on first run (and to seed a missing settings.json): /// the hardcoded fallback model and a low reasoning effort. @@ -211,6 +235,34 @@ pub async fn get_settings() -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use providers::{OpenRouterProvider, ProviderId}; + + fn registry() -> ProviderRegistry { + let openrouter = OpenRouterProvider::builder("key") + .build() + .expect("builds without network"); + ProviderRegistry::new([std::sync::Arc::new(openrouter) as std::sync::Arc]) + } + + #[test] + fn a_missing_provider_falls_back_to_the_default_model() { + let (provider, model, notice) = + startup_provider(®istry(), "gone", "some-model".into()).unwrap(); + + assert_eq!(provider.id(), ProviderId::new(DEFAULT_PROVIDER)); + assert_eq!(model, DEFAULT_MODEL); + assert!(notice.unwrap().contains("gone")); + } + + #[test] + fn a_known_provider_keeps_the_saved_model() { + let (provider, model, notice) = + startup_provider(®istry(), DEFAULT_PROVIDER, "some-model".into()).unwrap(); + + assert_eq!(provider.id(), ProviderId::new(DEFAULT_PROVIDER)); + assert_eq!(model, "some-model"); + assert!(notice.is_none()); + } fn test_store_path(label: &str) -> PathBuf { std::env::temp_dir().join(format!("alan-profiles-{}-{label}.json", std::process::id())) diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index b758605..439cc58 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -23,7 +23,7 @@ use agent::{Agent, SessionManager, ToolPermissionManager, default_tools}; use alan_tui::Runtime; use llm::ReasoningEffort; use providers::{ - DeepSeekProvider, FileCredentialStore, ModelOptions, OpenRouterProvider, Provider, ProviderId, + DeepSeekProvider, FileCredentialStore, ModelOptions, OpenRouterProvider, Provider, ProviderRegistry, ZaiProvider, bind_model, }; use std::path::PathBuf; @@ -63,7 +63,7 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&credential_store_for_providers), )); if let Err(error) = custom_providers.load().await { - tracing::warn!(error = %error, "failed to load custom providers"); + tracing::warn!(%error, "failed to load custom providers"); } let session_manager = Arc::new(SessionManager::new(sessions_path()?)); let resumed_session = load_resumed_session(&session_manager).await?; @@ -165,8 +165,6 @@ async fn load_resumed_session( Ok(Some(session_manager.get_session(&session_id, &cwd).await?)) } -/// The model to start on, and a notice when the saved provider no longer -/// exists: removed with `/provider remove`, or a typo in `ALAN_PROVIDER`. fn select_model( registry: &ProviderRegistry, settings: &Settings, @@ -182,22 +180,8 @@ fn select_model( .unwrap_or_else(|| DEFAULT_MODEL.into()), ), }; - let (provider, model_id, notice) = match registry.get(&ProviderId::new(provider_id)) { - Some(provider) => (provider, model_id, None), - None => { - tracing::warn!( - provider = provider_id, - "saved provider not found, starting on the default model" - ); - let provider = registry - .get(&ProviderId::new(DEFAULT_PROVIDER)) - .ok_or_else(|| anyhow::anyhow!("Provider not found: {DEFAULT_PROVIDER}"))?; - let notice = format!( - "provider {provider_id} not found; using {DEFAULT_PROVIDER} — {DEFAULT_MODEL}" - ); - (provider, DEFAULT_MODEL.to_owned(), Some(notice)) - } - }; + let (provider, model_id, notice) = + core::settings::startup_provider(registry, provider_id, model_id)?; let server_tools = server_tools( provider.server_tools(), settings.web_fetch == Some(true), @@ -341,43 +325,3 @@ fn configured_session_id() -> anyhow::Result> { } Ok(Some(id)) } - -#[cfg(test)] -mod tests { - use super::*; - - fn registry() -> ProviderRegistry { - let openrouter = OpenRouterProvider::builder("key") - .build() - .expect("builds without network"); - ProviderRegistry::new([Arc::new(openrouter) as Arc]) - } - - #[test] - fn a_missing_provider_falls_back_to_the_default_model() { - let settings = Settings { - provider: Some("gone".into()), - model: Some("some-model".into()), - ..Settings::with_defaults() - }; - - let (model, notice) = select_model(®istry(), &settings, None).unwrap(); - - assert_eq!(model.info().provider, ProviderId::new(DEFAULT_PROVIDER)); - assert_eq!(model.info().id, DEFAULT_MODEL); - assert!(notice.unwrap().contains("gone")); - } - - #[test] - fn a_known_provider_keeps_the_saved_model() { - let settings = Settings { - model: Some("some-model".into()), - ..Settings::with_defaults() - }; - - let (model, notice) = select_model(®istry(), &settings, None).unwrap(); - - assert_eq!(model.info().id, "some-model"); - assert!(notice.is_none()); - } -} From c7e964ade27f28beab202e3b1081d8b620b7b9e5 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:16:54 +0530 Subject: [PATCH 14/36] Match the house style in the provider code and clean up its test files --- crates/alan/src/core/custom_providers.rs | 32 +++++++++++++++++++----- crates/alan/src/provider_overlay.rs | 15 ++++++----- crates/providers/src/custom.rs | 5 ++-- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 01cc046..9b4b0b5 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -188,7 +188,7 @@ impl CustomProviders { pub async fn add(&self, name: ProviderName, url: &str, key: Option) -> Result { anyhow::ensure!( self.registry.get(&name.id()).is_none(), - "a provider named {name} already exists" + "provider already exists: {name}" ); self.save(name, url, key).await } @@ -270,15 +270,25 @@ mod tests { const OK_TWO_MODELS: &str = "HTTP/1.1 200 OK\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"},{\"id\":\"m2\"}]}"; - fn temp_path(name: &str) -> PathBuf { - let path = temp_dir().join(format!( + fn store_path(name: &str) -> PathBuf { + temp_dir().join(format!( "alan-custom-providers-test-{}-{name}.json", std::process::id() - )); + )) + } + + fn temp_path(name: &str) -> PathBuf { + let path = store_path(name); let _ = std::fs::remove_file(&path); path } + fn cleanup(name: &str) { + let path = store_path(name); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(format!("{}.lock", path.display())); + } + fn name(value: &str) -> ProviderName { ProviderName::parse(value).unwrap() } @@ -300,7 +310,7 @@ mod tests { (providers, registry, credentials) } - // one-shot server: replies `response`, hands back the raw request. + // one-shot server: replies `response`. async fn serve_once(response: &'static str) -> String { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) @@ -392,6 +402,7 @@ mod tests { .map(|e| e.url) .collect(); assert_eq!(urls, ["http://three", "http://two"]); + cleanup("put"); } #[tokio::test] @@ -407,6 +418,7 @@ mod tests { assert!(store.remove(&name("a")).await.unwrap()); assert!(!store.remove(&name("a")).await.unwrap()); assert!(store.load().await.unwrap().is_empty()); + cleanup("remove"); } #[tokio::test] @@ -420,6 +432,7 @@ mod tests { .await .unwrap(); assert!(CustomProviderStore::new(path).load().await.is_err()); + cleanup("corrupt"); } #[tokio::test] @@ -448,6 +461,7 @@ mod tests { ); let provider = registry.get(&name("grok").id()).unwrap(); assert_eq!(provider.models().len(), 2); + cleanup("add"); } #[tokio::test] @@ -470,6 +484,7 @@ mod tests { .is_none() ); assert!(registry.get(&name("grok").id()).is_none()); + cleanup("add-fails"); } #[tokio::test] @@ -488,7 +503,7 @@ mod tests { .await .unwrap_err(); - assert!(error.to_string().contains("already exists"), "{error:#}"); + assert_eq!(error.to_string(), "provider already exists: taken"); } #[tokio::test] @@ -503,6 +518,7 @@ mod tests { providers.update(name("grok"), &url, None).await.unwrap(); assert!(providers.key(&name("grok")).await.unwrap().is_none()); + cleanup("update"); } #[tokio::test] @@ -537,6 +553,7 @@ mod tests { requests[1].starts_with("POST /v1/chat/completions"), "{requests:?}" ); + cleanup("edit-bound"); } #[tokio::test] @@ -556,6 +573,7 @@ mod tests { assert!(providers.entries().await.unwrap().is_empty()); assert!(providers.key(&name("grok")).await.unwrap().is_none()); assert!(registry.get(&name("grok").id()).is_none()); + cleanup("remove-all"); } #[tokio::test] @@ -571,6 +589,7 @@ mod tests { assert!(error.to_string().contains("in use"), "{error:#}"); assert_eq!(providers.entries().await.unwrap().len(), 1); + cleanup("remove-active"); } #[tokio::test] @@ -594,5 +613,6 @@ mod tests { let provider = registry.get(&name("grok").id()).unwrap(); assert!(provider.models().is_empty()); + cleanup("load"); } } diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs index 50f2d22..7f4ca07 100644 --- a/crates/alan/src/provider_overlay.rs +++ b/crates/alan/src/provider_overlay.rs @@ -12,6 +12,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap}; use std::sync::Arc; +use unicode_width::UnicodeWidthStr; const FIELD_COUNT: usize = 4; const FIELD_LABELS: [&str; FIELD_COUNT] = ["Name", "URL", "API", "API Key"]; @@ -347,7 +348,7 @@ fn render_fields( ) { let max_label = FIELD_LABELS .iter() - .map(|label| label.len()) + .map(|label| label.width()) .max() .unwrap_or(0); for (i, label) in FIELD_LABELS.iter().enumerate() { @@ -370,10 +371,10 @@ fn render_fields( _ if fields.field(i).is_empty() => Span::styled("required", muted), _ => Span::styled(fields.field(i), value_style), }; - let line = Line::from(vec![ + let mut spans = vec![ Span::styled(if is_focused { " › " } else { " " }, accent), Span::styled( - format!("{label:>max_label$} "), + format!("{}{label} ", " ".repeat(max_label - label.width())), if is_focused { accent.add_modifier(Modifier::BOLD) } else { @@ -381,8 +382,10 @@ fn render_fields( }, ), Span::styled(": ", muted), - value, - ]); + ]; + let value_column: usize = spans.iter().map(Span::width).sum(); + spans.push(value); + let line = Line::from(spans); frame.render_widget( Paragraph::new(line).style(if is_focused { Style::default().bg(theme::SELECTION_BG) @@ -397,7 +400,7 @@ fn render_fields( Paragraph::new(Span::styled( format!( "{}the part before /chat/completions, e.g. https…/v1", - " ".repeat(max_label + 7) + " ".repeat(value_column) ), muted, )), diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index f4ad7cc..dfe2394 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -1,6 +1,3 @@ -//! A provider the user adds at runtime: any server that speaks Chat -//! Completions, known by a name and a base URL. - use crate::{ ApiId, AuthError, AuthMethod, AuthResolver, AuthResult, Credential, CredentialStore, ModelInfo, Provider, ProviderError, ProviderId, ServerToolInfo, @@ -23,6 +20,8 @@ const CONTEXT_FIELDS: [&str; 4] = [ "max_model_len", ]; +/// A provider the user adds at runtime: any server that speaks Chat +/// Completions, known by a name and a base URL. pub struct CustomProvider { id: ProviderId, /// Shared with the API client, so models already bound to this provider From c1331f01776fdf0f7567c2f0eb64cce009172e44 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:33:40 +0530 Subject: [PATCH 15/36] Tell /provider and /model-providers apart in /help --- crates/alan/src/core/command.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/alan/src/core/command.rs b/crates/alan/src/core/command.rs index 52270b2..16b44b1 100644 --- a/crates/alan/src/core/command.rs +++ b/crates/alan/src/core/command.rs @@ -31,7 +31,6 @@ pub enum SlashCommand { ToolSlip, ToolStrict, Profile, - // Add, edit or remove a Chat Completions provider. Provider, Quit, } @@ -102,13 +101,13 @@ impl SlashCommand { Self::Effort => "set reasoning effort (e.g. /effort high)", Self::Fork => "fork this session from a checkpoint", Self::Help => "list the available commands", - Self::ModelProviders => "pick a provider from openrouter for the selected model", + Self::ModelProviders => "openrouter only: pick which upstream serves this model", Self::Rename => "rename the current session", Self::ToolFree => "allow all tool calls without asking", Self::ToolSlip => "approve a command family once, allow its siblings", Self::ToolStrict => "allow only exact commands already approved", Self::Profile => "save and switch named model profiles", - Self::Provider => "add, edit or remove a Chat Completions provider", + Self::Provider => "add, edit or remove your own Chat Completions server", Self::Quit => "abort and quit", } } From 27abc6e515fcd7be0485cdad82d2194fb36cb6a9 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Tue, 13 Oct 2026 12:43:08 +0530 Subject: [PATCH 16/36] Exit with a way back in when the saved provider is gone, not switch models silently --- CHANGELOG.md | 4 --- crates/alan/src/core/settings.rs | 54 +------------------------------- crates/alan/src/main.rs | 32 ++++++++++++------- 3 files changed, 22 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e96bc..3995991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/provider add`, `/provider edit` and `/provider remove` to use any Chat Completions server by name. Alan checks the server's `/models` before saving, lists all of its models in `/models`, and keeps the key in `auth.json` so `/login` can change it. -### Fixed - -- When the saved provider no longer exists, Alan starts on the default model and says so, instead of failing to start. - ## 0.1.5 - 2026-10-07 ### Added diff --git a/crates/alan/src/core/settings.rs b/crates/alan/src/core/settings.rs index a12b6b9..0cc4c8f 100644 --- a/crates/alan/src/core/settings.rs +++ b/crates/alan/src/core/settings.rs @@ -22,10 +22,9 @@ use crate::core::store::JsonStore; const SETTINGS_KEY: &str = "settings"; use anyhow::Context as _; use llm::ReasoningEffort; -use providers::{OpenRouterOptions, Provider, ProviderId, ProviderRegistry}; +use providers::OpenRouterOptions; use std::collections::BTreeMap; use std::path::PathBuf; -use std::sync::Arc; /// Public Settings representing current configuration. All fields are optional so /// that we can distinguish between a field that has not been provided by the @@ -130,29 +129,6 @@ pub fn default_settings_path() -> Result { pub const DEFAULT_MODEL: &str = "openai/gpt-4o-mini"; pub const DEFAULT_PROVIDER: &str = "openrouter"; -/// The provider and model to start on, and a notice when the saved provider -/// no longer exists: removed with `/provider remove`, or a typo in -/// `ALAN_PROVIDER`. -pub fn startup_provider( - registry: &ProviderRegistry, - provider_id: &str, - model_id: String, -) -> Result<(Arc, String, Option)> { - if let Some(provider) = registry.get(&ProviderId::new(provider_id)) { - return Ok((provider, model_id, None)); - } - tracing::warn!( - provider = provider_id, - "saved provider not found, starting on the default model" - ); - let provider = registry - .get(&ProviderId::new(DEFAULT_PROVIDER)) - .with_context(|| format!("Provider not found: {DEFAULT_PROVIDER}"))?; - let notice = - format!("provider {provider_id} not found; using {DEFAULT_PROVIDER} — {DEFAULT_MODEL}"); - Ok((provider, DEFAULT_MODEL.to_owned(), Some(notice))) -} - impl Settings { /// Settings used on first run (and to seed a missing settings.json): /// the hardcoded fallback model and a low reasoning effort. @@ -235,34 +211,6 @@ pub async fn get_settings() -> anyhow::Result { #[cfg(test)] mod tests { use super::*; - use providers::{OpenRouterProvider, ProviderId}; - - fn registry() -> ProviderRegistry { - let openrouter = OpenRouterProvider::builder("key") - .build() - .expect("builds without network"); - ProviderRegistry::new([std::sync::Arc::new(openrouter) as std::sync::Arc]) - } - - #[test] - fn a_missing_provider_falls_back_to_the_default_model() { - let (provider, model, notice) = - startup_provider(®istry(), "gone", "some-model".into()).unwrap(); - - assert_eq!(provider.id(), ProviderId::new(DEFAULT_PROVIDER)); - assert_eq!(model, DEFAULT_MODEL); - assert!(notice.unwrap().contains("gone")); - } - - #[test] - fn a_known_provider_keeps_the_saved_model() { - let (provider, model, notice) = - startup_provider(®istry(), DEFAULT_PROVIDER, "some-model".into()).unwrap(); - - assert_eq!(provider.id(), ProviderId::new(DEFAULT_PROVIDER)); - assert_eq!(model, "some-model"); - assert!(notice.is_none()); - } fn test_store_path(label: &str) -> PathBuf { std::env::temp_dir().join(format!("alan-profiles-{}-{label}.json", std::process::id())) diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 439cc58..eb272a0 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -23,7 +23,7 @@ use agent::{Agent, SessionManager, ToolPermissionManager, default_tools}; use alan_tui::Runtime; use llm::ReasoningEffort; use providers::{ - DeepSeekProvider, FileCredentialStore, ModelOptions, OpenRouterProvider, Provider, + DeepSeekProvider, FileCredentialStore, ModelOptions, OpenRouterProvider, Provider, ProviderId, ProviderRegistry, ZaiProvider, bind_model, }; use std::path::PathBuf; @@ -67,7 +67,7 @@ async fn main() -> anyhow::Result<()> { } let session_manager = Arc::new(SessionManager::new(sessions_path()?)); let resumed_session = load_resumed_session(&session_manager).await?; - let (model, startup_notice) = select_model(®istry, &settings, resumed_session.as_ref())?; + let model = select_model(®istry, &settings, resumed_session.as_ref())?; let current_dir = std::env::current_dir()?; @@ -94,9 +94,6 @@ async fn main() -> anyhow::Result<()> { let model_name = agent.info().await.name; let mut controller = ChatController::new(agent, model_name); - if let Some(notice) = startup_notice { - controller.push_info(notice); - } if was_resumed { controller.restore_session_history().await; @@ -169,7 +166,7 @@ fn select_model( registry: &ProviderRegistry, settings: &Settings, resumed_session: Option<&agent::Session>, -) -> anyhow::Result<(providers::Model, Option)> { +) -> anyhow::Result { let (provider_id, model_id) = match resumed_session { Some(session) => (session.provider.as_str(), session.model.clone()), None => ( @@ -180,14 +177,15 @@ fn select_model( .unwrap_or_else(|| DEFAULT_MODEL.into()), ), }; - let (provider, model_id, notice) = - core::settings::startup_provider(registry, provider_id, model_id)?; + let provider = registry + .get(&ProviderId::new(provider_id)) + .ok_or_else(|| missing_provider(provider_id, resumed_session))?; let server_tools = server_tools( provider.server_tools(), settings.web_fetch == Some(true), settings.web_search == Some(true), ); - let model = bind_model( + Ok(bind_model( provider.as_ref(), &model_id, ModelOptions { @@ -195,8 +193,20 @@ fn select_model( reasoning_effort: settings.reasoning.unwrap_or_default(), extensions: settings.extensions(&model_id), }, - )?; - Ok((model, notice)) + )?) +} + +/// Alan exits here before `/models` can be opened, so the error says how to +/// start again. +fn missing_provider(provider_id: &str, resumed_session: Option<&agent::Session>) -> anyhow::Error { + match resumed_session { + Some(_) => anyhow::anyhow!( + "this session used provider {provider_id}, which no longer exists; add it again with /provider add, then resume" + ), + None => anyhow::anyhow!( + "provider {provider_id} not found; start with `ALAN_PROVIDER={DEFAULT_PROVIDER} ALAN_MODEL={DEFAULT_MODEL} alan` and pick a model with /models" + ), + } } fn build_tool_policy( From a3a4197036936a5894673760abe7ef6cc063d59d Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 11:54:44 +0530 Subject: [PATCH 17/36] Keep the original Provider not found error at startup --- crates/alan/src/main.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index eb272a0..8452884 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -57,6 +57,7 @@ async fn main() -> anyhow::Result<()> { credential_store.clone() as Arc; let providers = build_providers(&credential_store_for_providers)?; let registry = Arc::new(ProviderRegistry::new(providers)); + let custom_providers = Arc::new(CustomProviders::new( CustomProviderStore::new(custom_providers::default_providers_path()?), Arc::clone(®istry), @@ -179,7 +180,7 @@ fn select_model( }; let provider = registry .get(&ProviderId::new(provider_id)) - .ok_or_else(|| missing_provider(provider_id, resumed_session))?; + .ok_or_else(|| anyhow::anyhow!("Provider not found: {provider_id}"))?; let server_tools = server_tools( provider.server_tools(), settings.web_fetch == Some(true), @@ -196,19 +197,6 @@ fn select_model( )?) } -/// Alan exits here before `/models` can be opened, so the error says how to -/// start again. -fn missing_provider(provider_id: &str, resumed_session: Option<&agent::Session>) -> anyhow::Error { - match resumed_session { - Some(_) => anyhow::anyhow!( - "this session used provider {provider_id}, which no longer exists; add it again with /provider add, then resume" - ), - None => anyhow::anyhow!( - "provider {provider_id} not found; start with `ALAN_PROVIDER={DEFAULT_PROVIDER} ALAN_MODEL={DEFAULT_MODEL} alan` and pick a model with /models" - ), - } -} - fn build_tool_policy( settings: &Settings, current_dir: &std::path::Path, From d2bbcd906d82742c296a66e2b43af55a54824b9f Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 11:56:01 +0530 Subject: [PATCH 18/36] Keep built-in and custom providers in separate tracks of the registry --- crates/alan/src/core/custom_providers.rs | 25 ++--- crates/providers/src/provider.rs | 124 ++++++++++++++++------- 2 files changed, 93 insertions(+), 56 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 9b4b0b5..77ea7ed 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -8,10 +8,9 @@ use providers::{ fetch_catalog, }; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fmt; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use crate::core::paths; use crate::core::store::JsonStore; @@ -25,7 +24,7 @@ const RESERVED_NAMES: [&str; 4] = ["openrouter", "deepseek", "zai", "google"]; /// A custom provider's name, and its id in `/models`, `auth.json`, /// `settings.json` and `ALAN_PROVIDER`. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct ProviderName(String); @@ -142,8 +141,6 @@ pub struct CustomProviders { store: CustomProviderStore, registry: Arc, credentials: Arc, - /// The registered instances, typed, so an edit can update one in place. - registered: Mutex>>, } impl CustomProviders { @@ -156,7 +153,6 @@ impl CustomProviders { store, registry, credentials, - registered: Mutex::new(HashMap::new()), } } @@ -212,11 +208,7 @@ impl CustomProviders { ); self.store.remove(name).await?; self.credentials.delete(&name.id()).await?; - self.registered - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(name); - self.registry.remove(&name.id()); + self.registry.remove_custom(&name.id()); Ok(()) } @@ -241,21 +233,18 @@ impl CustomProviders { } fn register(&self, entry: &CustomProviderEntry, models: Vec) { - let mut registered = self.registered.lock().unwrap_or_else(|e| e.into_inner()); // A bound model keeps its provider's API client: replacing the // provider on an edit would leave the running model on the old URL. - if let Some(provider) = registered.get(&entry.name) { + if let Some(provider) = self.registry.custom(&entry.name.id()) { provider.set_base_url(entry.url.clone()); return; } - let provider = Arc::new(CustomProvider::new( + self.registry.add_custom(Arc::new(CustomProvider::new( entry.name.id(), entry.url.clone(), Arc::clone(&self.credentials), models, - )); - registered.insert(entry.name.clone(), Arc::clone(&provider)); - self.registry.insert(provider); + ))); } } @@ -490,7 +479,7 @@ mod tests { #[tokio::test] async fn add_refuses_a_taken_name_before_any_request() { let (providers, registry, credentials) = service("taken"); - registry.insert(Arc::new(CustomProvider::new( + registry.add_custom(Arc::new(CustomProvider::new( name("taken").id(), "http://localhost:1/v1", credentials, diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 436ba82..0784fc3 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -1,6 +1,7 @@ use crate::Model; use crate::auth::AuthResolver; use crate::catalog::{ApiId, ModelInfo, ProviderId, ServerToolInfo}; +use crate::custom::CustomProvider; use crate::model::ModelOptions; use async_trait::async_trait; use llm::LlmApi; @@ -49,27 +50,50 @@ pub trait Provider: Send + Sync { } } +/// Every provider Alan can bind, in two tracks: the built-ins fixed at +/// startup, and the custom providers `/provider` adds and removes while Alan +/// runs. The custom track keeps their type so an edit can reach them. pub struct ProviderRegistry { - providers: RwLock>>, + builtin: Vec>, + custom: RwLock>>, } impl ProviderRegistry { - pub fn new(providers: impl IntoIterator>) -> Self { + pub fn new(builtin: impl IntoIterator>) -> Self { Self { - providers: RwLock::new(providers.into_iter().collect()), + builtin: builtin.into_iter().collect(), + custom: RwLock::new(Vec::new()), } } - /// A snapshot: `/provider` can change the set while Alan runs. + /// A snapshot of both tracks, built-ins first. pub fn providers(&self) -> Vec> { - self.providers - .read() - .unwrap_or_else(|e| e.into_inner()) - .clone() + let custom = self.custom.read().unwrap_or_else(|e| e.into_inner()); + self.builtin + .iter() + .cloned() + .chain( + custom + .iter() + .map(|provider| Arc::clone(provider) as Arc), + ) + .collect() } pub fn get(&self, id: &ProviderId) -> Option> { - self.providers + self.builtin + .iter() + .find(|p| p.id() == *id) + .cloned() + .or_else(|| self.custom(id).map(|p| p as Arc)) + } + + pub fn is_builtin(&self, id: &ProviderId) -> bool { + self.builtin.iter().any(|p| p.id() == *id) + } + + pub fn custom(&self, id: &ProviderId) -> Option> { + self.custom .read() .unwrap_or_else(|e| e.into_inner()) .iter() @@ -77,23 +101,18 @@ impl ProviderRegistry { .cloned() } - /// Register `provider`, replacing one already registered under its id. - pub fn insert(&self, provider: Arc) { - let mut providers = self.providers.write().unwrap_or_else(|e| e.into_inner()); - match providers - .iter_mut() - .find(|existing| existing.id() == provider.id()) - { - Some(existing) => *existing = provider, - None => providers.push(provider), - } + pub fn add_custom(&self, provider: Arc) { + self.custom + .write() + .unwrap_or_else(|e| e.into_inner()) + .push(provider); } - pub fn remove(&self, id: &ProviderId) -> bool { - let mut providers = self.providers.write().unwrap_or_else(|e| e.into_inner()); - let before = providers.len(); - providers.retain(|provider| provider.id() != *id); - providers.len() < before + pub fn remove_custom(&self, id: &ProviderId) -> bool { + let mut custom = self.custom.write().unwrap_or_else(|e| e.into_inner()); + let before = custom.len(); + custom.retain(|provider| provider.id() != *id); + custom.len() < before } } @@ -127,7 +146,7 @@ mod tests { use super::*; use crate::{CustomProvider, InMemoryCredentialStore}; - fn provider(name: &str, model: &str) -> Arc { + fn custom(name: &str, model: &str) -> Arc { Arc::new(CustomProvider::new( ProviderId::new(name), "http://localhost:11434/v1", @@ -136,22 +155,51 @@ mod tests { )) } + // Any provider stands in for a built-in: only the track it sits in matters. + fn builtin(name: &str) -> Arc { + custom(name, "m") + } + + #[test] + fn providers_lists_builtins_before_custom() { + let registry = ProviderRegistry::new([builtin("openrouter")]); + registry.add_custom(custom("grok", "grok-4")); + + let ids: Vec<_> = registry.providers().iter().map(|p| p.id().0).collect(); + + assert_eq!(ids, ["openrouter", "grok"]); + } + + #[test] + fn get_finds_both_tracks_and_is_builtin_only_the_first() { + let registry = ProviderRegistry::new([builtin("openrouter")]); + registry.add_custom(custom("grok", "grok-4")); + + assert!(registry.get(&ProviderId::new("openrouter")).is_some()); + assert!(registry.get(&ProviderId::new("grok")).is_some()); + assert!(registry.is_builtin(&ProviderId::new("openrouter"))); + assert!(!registry.is_builtin(&ProviderId::new("grok"))); + } + #[test] - fn insert_adds_new_ids_and_replaces_existing_ones() { - let registry = ProviderRegistry::new([provider("a", "old")]); - registry.insert(provider("a", "new")); - registry.insert(provider("b", "m")); - - assert_eq!(registry.providers().len(), 2); - let a = registry.get(&ProviderId::new("a")).unwrap(); - assert_eq!(a.models()[0].id, "new"); + fn custom_returns_the_typed_provider_from_the_custom_track_only() { + let registry = ProviderRegistry::new([builtin("openrouter")]); + registry.add_custom(custom("grok", "grok-4")); + + let grok = registry.custom(&ProviderId::new("grok")).unwrap(); + + assert_eq!(grok.models()[0].id, "grok-4"); + assert!(registry.custom(&ProviderId::new("openrouter")).is_none()); } #[test] - fn remove_reports_whether_a_provider_went() { - let registry = ProviderRegistry::new([provider("a", "m")]); - assert!(registry.remove(&ProviderId::new("a"))); - assert!(!registry.remove(&ProviderId::new("a"))); - assert!(registry.get(&ProviderId::new("a")).is_none()); + fn remove_custom_leaves_builtins_alone() { + let registry = ProviderRegistry::new([builtin("openrouter")]); + registry.add_custom(custom("grok", "grok-4")); + + assert!(registry.remove_custom(&ProviderId::new("grok"))); + assert!(!registry.remove_custom(&ProviderId::new("grok"))); + assert!(!registry.remove_custom(&ProviderId::new("openrouter"))); + assert!(registry.get(&ProviderId::new("openrouter")).is_some()); } } From 7fcb94b242b329d89093d9db43cc7e9655ed11a4 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 11:57:43 +0530 Subject: [PATCH 19/36] Record each custom provider's API standard so others can follow Chat Completions --- crates/alan/src/core/custom_providers.rs | 107 ++++++++++++----- crates/alan/src/provider_overlay.rs | 20 +++- .../src/views/components/chat_view/models.rs | 16 ++- crates/providers/src/custom.rs | 112 ++++++++++++++---- crates/providers/src/provider.rs | 18 ++- 5 files changed, 206 insertions(+), 67 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 77ea7ed..fba5f68 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use providers::{ - Credential, CredentialStore, CustomProvider, ModelInfo, ProviderId, ProviderRegistry, + ApiId, Credential, CredentialStore, CustomProvider, ModelInfo, ProviderId, ProviderRegistry, fetch_catalog, }; use serde::{Deserialize, Serialize}; @@ -73,6 +73,7 @@ impl fmt::Display for ProviderName { pub struct CustomProviderEntry { pub name: ProviderName, pub url: String, + pub api: ApiId, } /// `providers.json`: the saved custom providers, in the order they were added. @@ -160,7 +161,7 @@ impl CustomProviders { /// `/models` opens. pub async fn load(&self) -> Result<()> { for entry in self.store.load().await? { - self.register(&entry, Vec::new()); + self.register(&entry, Vec::new())?; } Ok(()) } @@ -181,22 +182,29 @@ impl CustomProviders { /// Check `url` with a `/models` call, then save the provider and its key. /// Returns how many models the server lists. Nothing is saved when the /// check fails. - pub async fn add(&self, name: ProviderName, url: &str, key: Option) -> Result { + pub async fn add( + &self, + name: ProviderName, + api: ApiId, + url: &str, + key: Option, + ) -> Result { anyhow::ensure!( self.registry.get(&name.id()).is_none(), "provider already exists: {name}" ); - self.save(name, url, key).await + self.save(name, api, url, key).await } /// [`add`](Self::add) for a saved provider. No key removes the saved one. pub async fn update( &self, name: ProviderName, + api: ApiId, url: &str, key: Option, ) -> Result { - self.save(name, url, key).await + self.save(name, api, url, key).await } /// Refuses the provider of the running model: its next request would go @@ -212,9 +220,15 @@ impl CustomProviders { Ok(()) } - async fn save(&self, name: ProviderName, url: &str, key: Option) -> Result { + async fn save( + &self, + name: ProviderName, + api: ApiId, + url: &str, + key: Option, + ) -> Result { let url = url.trim().trim_end_matches('/').to_owned(); - let models = fetch_catalog(&name.id(), &url, key.as_deref()) + let models = fetch_catalog(&name.id(), &api, &url, key.as_deref()) .await .with_context(|| format!("checking {url}/models"))?; let count = models.len(); @@ -226,25 +240,27 @@ impl CustomProviders { } None => self.credentials.delete(&name.id()).await?, } - let entry = CustomProviderEntry { name, url }; + let entry = CustomProviderEntry { name, url, api }; self.store.put(entry.clone()).await?; - self.register(&entry, models); + self.register(&entry, models)?; Ok(count) } - fn register(&self, entry: &CustomProviderEntry, models: Vec) { + fn register(&self, entry: &CustomProviderEntry, models: Vec) -> Result<()> { // A bound model keeps its provider's API client: replacing the // provider on an edit would leave the running model on the old URL. if let Some(provider) = self.registry.custom(&entry.name.id()) { provider.set_base_url(entry.url.clone()); - return; + return Ok(()); } self.registry.add_custom(Arc::new(CustomProvider::new( entry.name.id(), + entry.api.clone(), entry.url.clone(), Arc::clone(&self.credentials), models, - ))); + )?)); + Ok(()) } } @@ -379,6 +395,7 @@ mod tests { .put(CustomProviderEntry { name: name(n), url: url.into(), + api: ApiId::ChatCompletions, }) .await .unwrap(); @@ -401,6 +418,7 @@ mod tests { .put(CustomProviderEntry { name: name("a"), url: "http://one".into(), + api: ApiId::ChatCompletions, }) .await .unwrap(); @@ -416,7 +434,7 @@ mod tests { JsonStore::new(path.clone()) .set( PROVIDERS_KEY, - json!([{"name": "Bad Name", "url": "http://x"}]), + json!([{"name": "Bad Name", "url": "http://x", "api": "ChatCompletions"}]), ) .await .unwrap(); @@ -430,13 +448,19 @@ mod tests { let url = serve_once(OK_TWO_MODELS).await; let count = providers - .add(name("grok"), &url, Some("sk".into())) + .add( + name("grok"), + ApiId::ChatCompletions, + &url, + Some("sk".into()), + ) .await .unwrap(); assert_eq!(count, 2); let entries = providers.entries().await.unwrap(); assert_eq!(entries[0].url, url.trim_end_matches('/')); + assert_eq!(entries[0].api, ApiId::ChatCompletions); assert_eq!( providers.key(&name("grok")).await.unwrap().as_deref(), Some("sk") @@ -459,7 +483,12 @@ mod tests { let url = serve_once("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}").await; let error = providers - .add(name("grok"), &url, Some("bad".into())) + .add( + name("grok"), + ApiId::ChatCompletions, + &url, + Some("bad".into()), + ) .await .unwrap_err(); @@ -479,16 +508,25 @@ mod tests { #[tokio::test] async fn add_refuses_a_taken_name_before_any_request() { let (providers, registry, credentials) = service("taken"); - registry.add_custom(Arc::new(CustomProvider::new( - name("taken").id(), - "http://localhost:1/v1", - credentials, - Vec::new(), - ))); + registry.add_custom(Arc::new( + CustomProvider::new( + name("taken").id(), + ApiId::ChatCompletions, + "http://localhost:1/v1", + credentials, + Vec::new(), + ) + .unwrap(), + )); // Port 1 is closed: reaching the network would say "could not connect". let error = providers - .add(name("taken"), "http://127.0.0.1:1/v1", None) + .add( + name("taken"), + ApiId::ChatCompletions, + "http://127.0.0.1:1/v1", + None, + ) .await .unwrap_err(); @@ -504,7 +542,10 @@ mod tests { .unwrap(); let url = serve_once(OK_TWO_MODELS).await; - providers.update(name("grok"), &url, None).await.unwrap(); + providers + .update(name("grok"), ApiId::ChatCompletions, &url, None) + .await + .unwrap(); assert!(providers.key(&name("grok")).await.unwrap().is_none()); cleanup("update"); @@ -514,14 +555,17 @@ mod tests { async fn update_moves_models_already_bound_to_the_new_url() { let (providers, registry, _credentials) = service("edit-bound"); let old_url = serve_once(OK_TWO_MODELS).await; - providers.add(name("grok"), &old_url, None).await.unwrap(); + providers + .add(name("grok"), ApiId::ChatCompletions, &old_url, None) + .await + .unwrap(); let provider = registry.get(&name("grok").id()).unwrap(); let model = providers::bind_model(provider.as_ref(), "m1", Default::default()).unwrap(); // The new server answers the edit's check, then the chat request. let (new_url, server) = serve_many(2).await; providers - .update(name("grok"), &new_url, None) + .update(name("grok"), ApiId::ChatCompletions, &new_url, None) .await .unwrap(); let messages = [llm::Message::user("hi")]; @@ -550,7 +594,12 @@ mod tests { let (providers, registry, _credentials) = service("remove-all"); let url = serve_once(OK_TWO_MODELS).await; providers - .add(name("grok"), &url, Some("sk".into())) + .add( + name("grok"), + ApiId::ChatCompletions, + &url, + Some("sk".into()), + ) .await .unwrap(); @@ -569,7 +618,10 @@ mod tests { async fn remove_refuses_the_provider_in_use() { let (providers, _registry, _credentials) = service("remove-active"); let url = serve_once(OK_TWO_MODELS).await; - providers.add(name("grok"), &url, None).await.unwrap(); + providers + .add(name("grok"), ApiId::ChatCompletions, &url, None) + .await + .unwrap(); let error = providers .remove(&name("grok"), &name("grok").id()) @@ -588,6 +640,7 @@ mod tests { .put(CustomProviderEntry { name: name("grok"), url: "http://localhost:1/v1".into(), + api: ApiId::ChatCompletions, }) .await .unwrap(); diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs index 7f4ca07..f2a1d4b 100644 --- a/crates/alan/src/provider_overlay.rs +++ b/crates/alan/src/provider_overlay.rs @@ -6,6 +6,7 @@ use crate::views::theme; use alan_tui::context::Context; use alan_tui::{ActionStatus, Component, RenderContext, TaskError}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use providers::ApiId; use ratatui::Frame; use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Modifier, Style}; @@ -86,6 +87,7 @@ enum KeyOutcome { pub struct ProviderOverlay { providers: Arc, mode: Mode, + api: ApiId, fields: FormFields, focused: usize, state: OverlayState, @@ -93,7 +95,13 @@ pub struct ProviderOverlay { impl ProviderOverlay { pub fn add(providers: Arc) -> Self { - Self::new(providers, Mode::Add, FormFields::default(), NAME) + Self::new( + providers, + Mode::Add, + ApiId::ChatCompletions, + FormFields::default(), + NAME, + ) } /// The name is fixed: `auth.json`, `settings.json` and profiles refer to @@ -108,18 +116,20 @@ impl ProviderOverlay { url: entry.url, api_key: key.unwrap_or_default(), }; - Self::new(providers, Mode::Edit, fields, URL) + Self::new(providers, Mode::Edit, entry.api, fields, URL) } fn new( providers: Arc, mode: Mode, + api: ApiId, fields: FormFields, focused: usize, ) -> Self { Self { providers, mode, + api, fields, focused, state: OverlayState::Editing, @@ -193,12 +203,13 @@ impl ProviderOverlay { }; let providers = Arc::clone(&self.providers); let mode = self.mode; + let api = self.api.clone(); cx.spawn( async move { let Submission { name, url, key } = submission; match mode { - Mode::Add => providers.add(name, &url, key).await, - Mode::Edit => providers.update(name, &url, key).await, + Mode::Add => providers.add(name, api, &url, key).await, + Mode::Edit => providers.update(name, api, &url, key).await, } .map_err(|error| TaskError(format!("{error:#}").into())) }, @@ -475,6 +486,7 @@ mod tests { let entry = CustomProviderEntry { name: ProviderName::parse("grok").unwrap(), url: "https://api.x.ai/v1".into(), + api: ApiId::ChatCompletions, }; let mut overlay = ProviderOverlay::edit(providers(), entry, Some("xai-1".into())); assert_eq!(overlay.focused, URL); diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 37ad8c9..0573243 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -403,12 +403,16 @@ mod tests { use providers::{CustomProvider, InMemoryCredentialStore, Provider}; fn provider(name: &str, model: &str) -> Arc { - Arc::new(CustomProvider::new( - ProviderId::new(name), - "http://localhost:11434/v1", - Arc::new(InMemoryCredentialStore::new()), - vec![ModelInfo::new(model, ProviderId::new(name))], - )) + Arc::new( + CustomProvider::new( + ProviderId::new(name), + providers::ApiId::ChatCompletions, + "http://localhost:11434/v1", + Arc::new(InMemoryCredentialStore::new()), + vec![ModelInfo::new(model, ProviderId::new(name))], + ) + .unwrap(), + ) } #[test] diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index dfe2394..71abfe4 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -24,6 +24,7 @@ const CONTEXT_FIELDS: [&str; 4] = [ /// Completions, known by a name and a base URL. pub struct CustomProvider { id: ProviderId, + api: ApiId, /// Shared with the API client, so models already bound to this provider /// follow [`set_base_url`](Self::set_base_url), as they follow key changes. base_url: Arc>, @@ -38,26 +39,31 @@ impl CustomProvider { /// second `/models` call; an empty list is filled on the next fetch. pub fn new( id: ProviderId, + api: ApiId, base_url: impl Into, credentials: Arc, models: Vec, - ) -> Self { + ) -> Result { let base_url = Arc::new(RwLock::new(base_url.into())); - let api: Arc = Arc::new(LiveUrlApi { - base_url: Arc::clone(&base_url), - http: Arc::new(HttpClient::new()), - }); - Self { + let client: Arc = match &api { + ApiId::ChatCompletions => Arc::new(LiveUrlApi { + base_url: Arc::clone(&base_url), + http: Arc::new(HttpClient::new()), + }), + other => return Err(ProviderError::UnsupportedApi(other.clone())), + }; + Ok(Self { auth: Arc::new(StoredKeyAuth { provider: id.clone(), credentials, }), - apis: HashMap::from([(ApiId::ChatCompletions, api)]), + apis: HashMap::from([(api.clone(), client)]), + api, models: RwLock::new(models), fetch_lock: tokio::sync::Mutex::new(()), base_url, id, - } + }) } /// Point the provider, and every model already bound to it, at `base_url`. @@ -125,10 +131,12 @@ impl Provider for CustomProvider { async fn validate_auth(&self, auth_result: &AuthResult) -> Result<(), AuthError> { match auth_result { - AuthResult::ApiKey(key) => fetch_catalog(&self.id, &self.base_url(), Some(key)) - .await - .map(drop) - .map_err(|error| AuthError::Validation(error.to_string())), + AuthResult::ApiKey(key) => { + fetch_catalog(&self.id, &self.api, &self.base_url(), Some(key)) + .await + .map(drop) + .map_err(|error| AuthError::Validation(error.to_string())) + } } } @@ -143,7 +151,7 @@ impl Provider for CustomProvider { RequestCredential::ApiKey(key) => Some(key), _ => None, }; - let models = fetch_catalog(&self.id, &self.base_url(), key.as_deref()).await?; + let models = fetch_catalog(&self.id, &self.api, &self.base_url(), key.as_deref()).await?; *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; Ok(()) } @@ -166,9 +174,22 @@ impl AuthResolver for StoredKeyAuth { } } -/// `GET {base_url}/models`. Chat Completions has no health endpoint, so this +/// The models a server lists. No API standard has a health endpoint, so this /// is how a URL and key are checked before a provider is saved. pub async fn fetch_catalog( + provider: &ProviderId, + api: &ApiId, + base_url: &str, + api_key: Option<&str>, +) -> Result, ProviderError> { + match api { + ApiId::ChatCompletions => fetch_chat_completions_catalog(provider, base_url, api_key).await, + other => Err(ProviderError::UnsupportedApi(other.clone())), + } +} + +/// `GET {base_url}/models` in the Chat Completions format. +async fn fetch_chat_completions_catalog( provider: &ProviderId, base_url: &str, api_key: Option<&str>, @@ -352,10 +373,36 @@ mod tests { ); } + #[tokio::test] + async fn unsupported_apis_are_refused_before_any_request() { + let provider = CustomProvider::new( + id(), + ApiId::AnthropicMessages, + "http://localhost:11434/v1", + Arc::new(InMemoryCredentialStore::new()), + Vec::new(), + ); + assert!(matches!( + provider, + Err(ProviderError::UnsupportedApi(ApiId::AnthropicMessages)) + )); + + // "not a url" would fail as "invalid URL" if a request were attempted. + let error = fetch_catalog(&id(), &ApiId::OpenAiResponses, "not a url", None) + .await + .unwrap_err(); + assert!(matches!( + error, + ProviderError::UnsupportedApi(ApiId::OpenAiResponses) + )); + } + #[tokio::test] async fn fetch_catalog_sends_key_to_models_path() { let (url, server) = serve_once(TWO_MODELS).await; - let models = fetch_catalog(&id(), &url, Some("sk-test")).await.unwrap(); + let models = fetch_catalog(&id(), &ApiId::ChatCompletions, &url, Some("sk-test")) + .await + .unwrap(); assert_eq!(models.len(), 2); let request = server.await.unwrap(); assert!(request.starts_with("GET /v1/models HTTP/1.1"), "{request}"); @@ -369,7 +416,9 @@ mod tests { #[tokio::test] async fn fetch_catalog_sends_no_auth_without_key() { let (url, server) = serve_once(TWO_MODELS).await; - fetch_catalog(&id(), &url, None).await.unwrap(); + fetch_catalog(&id(), &ApiId::ChatCompletions, &url, None) + .await + .unwrap(); assert!( !server .await @@ -385,7 +434,9 @@ mod tests { "HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{\"error\":\"Invalid API key\"}", ) .await; - let error = fetch_catalog(&id(), &url, Some("bad")).await.unwrap_err(); + let error = fetch_catalog(&id(), &ApiId::ChatCompletions, &url, Some("bad")) + .await + .unwrap_err(); assert!(error.to_string().contains("401"), "{error}"); } @@ -396,15 +447,22 @@ mod tests { .unwrap() .local_addr() .unwrap(); - let error = fetch_catalog(&id(), &format!("http://{address}/v1"), None) - .await - .unwrap_err(); + let error = fetch_catalog( + &id(), + &ApiId::ChatCompletions, + &format!("http://{address}/v1"), + None, + ) + .await + .unwrap_err(); assert!(error.to_string().contains("could not connect"), "{error}"); } #[tokio::test] async fn fetch_catalog_reports_invalid_url() { - let error = fetch_catalog(&id(), "not a url", None).await.unwrap_err(); + let error = fetch_catalog(&id(), &ApiId::ChatCompletions, "not a url", None) + .await + .unwrap_err(); assert!(error.to_string().contains("invalid URL"), "{error}"); } @@ -421,7 +479,9 @@ mod tests { ) .await .unwrap(); - let provider = CustomProvider::new(id(), url, credentials, Vec::new()); + let provider = + CustomProvider::new(id(), ApiId::ChatCompletions, url, credentials, Vec::new()) + .unwrap(); provider.fetch_models().await.unwrap(); assert_eq!(provider.models().len(), 2); assert!( @@ -437,10 +497,12 @@ mod tests { async fn provider_without_a_key_sends_no_credential() { let provider = CustomProvider::new( id(), + ApiId::ChatCompletions, "http://localhost:11434/v1", Arc::new(InMemoryCredentialStore::new()), Vec::new(), - ); + ) + .unwrap(); let credential = provider.auth().resolve().await.unwrap(); assert!(matches!(credential, RequestCredential::None)); } @@ -451,10 +513,12 @@ mod tests { serve_once("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}").await; let provider = CustomProvider::new( id(), + ApiId::ChatCompletions, url, Arc::new(InMemoryCredentialStore::new()), Vec::new(), - ); + ) + .unwrap(); let result = provider .validate_auth(&AuthResult::ApiKey("bad".into())) .await; diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 0784fc3..53e0bb1 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -19,6 +19,8 @@ pub enum ProviderError { MissingAuth, #[error("failed to fetch models: {0}")] Fetch(String), + #[error("{0:?} is not supported for custom providers yet")] + UnsupportedApi(ApiId), } #[async_trait] @@ -147,12 +149,16 @@ mod tests { use crate::{CustomProvider, InMemoryCredentialStore}; fn custom(name: &str, model: &str) -> Arc { - Arc::new(CustomProvider::new( - ProviderId::new(name), - "http://localhost:11434/v1", - Arc::new(InMemoryCredentialStore::new()), - vec![ModelInfo::new(model, ProviderId::new(name))], - )) + Arc::new( + CustomProvider::new( + ProviderId::new(name), + ApiId::ChatCompletions, + "http://localhost:11434/v1", + Arc::new(InMemoryCredentialStore::new()), + vec![ModelInfo::new(model, ProviderId::new(name))], + ) + .unwrap(), + ) } // Any provider stands in for a built-in: only the track it sits in matters. From 7d182a6ec7657a44163032ffe44f8d7dbda4ccbb Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 11:58:14 +0530 Subject: [PATCH 20/36] Let the built-in track reserve its own names, and suggest a free one --- crates/alan/src/core/custom_providers.rs | 72 ++++++++++++++++-------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index fba5f68..b7986ab 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -17,11 +17,6 @@ use crate::core::store::JsonStore; const PROVIDERS_KEY: &str = "providers"; -/// Built-in provider ids, plus `google`, which the providers crate has but -/// Alan does not register yet. A custom provider may not take one: its key -/// would overwrite the built-in provider's in `auth.json`. -const RESERVED_NAMES: [&str; 4] = ["openrouter", "deepseek", "zai", "google"]; - /// A custom provider's name, and its id in `/models`, `auth.json`, /// `settings.json` and `ALAN_PROVIDER`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -37,10 +32,6 @@ impl ProviderName { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), "name may only contain lowercase letters, digits and -" ); - anyhow::ensure!( - !RESERVED_NAMES.contains(&name), - "{name} is a built-in provider" - ); Ok(Self(name.to_owned())) } @@ -189,9 +180,14 @@ impl CustomProviders { url: &str, key: Option, ) -> Result { + // A built-in's key in `auth.json` sits under the same name. + anyhow::ensure!( + !self.registry.is_builtin(&name.id()), + "{name} is a built-in provider; pick another name, e.g. {name}-2" + ); anyhow::ensure!( - self.registry.get(&name.id()).is_none(), - "provider already exists: {name}" + self.registry.custom(&name.id()).is_none(), + "{name} already exists; change it with /provider edit" ); self.save(name, api, url, key).await } @@ -368,21 +364,48 @@ mod tests { } #[test] - fn provider_name_rejects_bad_and_reserved_names() { - for bad in [ - "", - "Grok", - "my groq", - "groq!", - "openrouter", - "deepseek", - "zai", - "google", - ] { + fn provider_name_rejects_bad_characters() { + for bad in ["", "Grok", "my groq", "groq!"] { assert!(ProviderName::parse(bad).is_err(), "{bad:?} was accepted"); } } + #[tokio::test] + async fn add_refuses_a_builtin_name_before_any_request() { + let deepseek = CustomProvider::new( + name("deepseek").id(), + ApiId::ChatCompletions, + "http://localhost:1/v1", + Arc::new(InMemoryCredentialStore::new()), + Vec::new(), + ) + .unwrap(); + let registry = Arc::new(ProviderRegistry::new([ + Arc::new(deepseek) as Arc + ])); + let providers = CustomProviders::new( + CustomProviderStore::new(temp_path("builtin")), + registry, + Arc::new(InMemoryCredentialStore::new()), + ); + + // Port 1 is closed: reaching the network would say "could not connect". + let error = providers + .add( + name("deepseek"), + ApiId::ChatCompletions, + "http://127.0.0.1:1/v1", + None, + ) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "deepseek is a built-in provider; pick another name, e.g. deepseek-2" + ); + } + #[tokio::test] async fn store_put_replaces_by_name_and_keeps_order() { let store = CustomProviderStore::new(temp_path("put")); @@ -530,7 +553,10 @@ mod tests { .await .unwrap_err(); - assert_eq!(error.to_string(), "provider already exists: taken"); + assert_eq!( + error.to_string(), + "taken already exists; change it with /provider edit" + ); } #[tokio::test] From a62b7674a837e660879e95cb92acdfbd69eb824a Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 11:58:56 +0530 Subject: [PATCH 21/36] Skip only the custom providers Alan cannot load, and say when a built-in took a name --- crates/alan/src/core/custom_providers.rs | 116 +++++++++++++++++++++-- crates/alan/src/main.rs | 13 ++- 2 files changed, 120 insertions(+), 9 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index b7986ab..830bf6e 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -83,6 +83,16 @@ impl CustomProviderStore { decode(self.store.get(PROVIDERS_KEY).await?) } + /// Each saved entry on its own, so one that does not parse leaves the + /// rest readable. + pub async fn load_each(&self) -> Result>> { + let Some(value) = self.store.get(PROVIDERS_KEY).await? else { + return Ok(Vec::new()); + }; + let values: Vec = serde_json::from_value(value)?; + Ok(values.into_iter().map(serde_json::from_value).collect()) + } + /// Save `entry`, replacing a saved provider with the same name. pub async fn put(&self, entry: CustomProviderEntry) -> Result<()> { self.store @@ -148,13 +158,31 @@ impl CustomProviders { } } - /// Register every saved provider. Their models are fetched the next time - /// `/models` opens. - pub async fn load(&self) -> Result<()> { - for entry in self.store.load().await? { - self.register(&entry, Vec::new())?; + /// Register every saved provider Alan can use; their models are fetched + /// the next time `/models` opens. Entries it cannot read or bind are + /// skipped. Returns a notice for each entry a built-in now takes the name of. + pub async fn load(&self) -> Result> { + let mut notices = Vec::new(); + for entry in self.store.load_each().await? { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(%error, "skipping an unreadable entry in providers.json"); + continue; + } + }; + if self.registry.is_builtin(&entry.name.id()) { + notices.push(format!( + "custom provider {} clashes with a built-in; remove it and add it under another name", + entry.name + )); + continue; + } + if let Err(error) = self.register(&entry, Vec::new()) { + tracing::warn!(%error, provider = %entry.name, "skipping a custom provider"); + } } - Ok(()) + Ok(notices) } pub async fn entries(&self) -> Result> { @@ -659,6 +687,82 @@ mod tests { cleanup("remove-active"); } + #[tokio::test] + async fn load_skips_entries_it_cannot_use_and_keeps_the_rest() { + let path = temp_path("load-skips"); + JsonStore::new(path.clone()) + .set( + PROVIDERS_KEY, + json!([ + {"name": "grok", "url": "http://localhost:1/v1", "api": "ChatCompletions"}, + {"name": "Bad Name", "url": "http://x", "api": "ChatCompletions"}, + {"name": "newer", "url": "http://x", "api": "SomeFutureStandard"}, + {"name": "claude", "url": "http://x", "api": "AnthropicMessages"} + ]), + ) + .await + .unwrap(); + let registry = Arc::new(ProviderRegistry::new([])); + let providers = CustomProviders::new( + CustomProviderStore::new(path), + Arc::clone(®istry), + Arc::new(InMemoryCredentialStore::new()), + ); + + let notices = providers.load().await.unwrap(); + + assert!(notices.is_empty(), "{notices:?}"); + let ids: Vec<_> = registry.providers().iter().map(|p| p.id().0).collect(); + assert_eq!(ids, ["grok"]); + cleanup("load-skips"); + } + + #[tokio::test] + async fn load_skips_a_name_a_builtin_now_uses_and_says_so() { + let path = temp_path("load-clash"); + let store = CustomProviderStore::new(path.clone()); + for n in ["openai", "grok"] { + store + .put(CustomProviderEntry { + name: name(n), + url: "http://localhost:1/v1".into(), + api: ApiId::ChatCompletions, + }) + .await + .unwrap(); + } + let openai = CustomProvider::new( + name("openai").id(), + ApiId::ChatCompletions, + "http://localhost:1/v1", + Arc::new(InMemoryCredentialStore::new()), + Vec::new(), + ) + .unwrap(); + let registry = Arc::new(ProviderRegistry::new([ + Arc::new(openai) as Arc + ])); + let providers = CustomProviders::new( + CustomProviderStore::new(path), + Arc::clone(®istry), + Arc::new(InMemoryCredentialStore::new()), + ); + + let notices = providers.load().await.unwrap(); + + assert_eq!( + notices, + [ + "custom provider openai clashes with a built-in; remove it and add it under another name" + ] + ); + assert!(registry.custom(&name("openai").id()).is_none()); + assert!(registry.custom(&name("grok").id()).is_some()); + // Still listed, so /provider remove can delete it. + assert_eq!(providers.entries().await.unwrap().len(), 2); + cleanup("load-clash"); + } + #[tokio::test] async fn load_registers_saved_providers_without_fetching() { let path = temp_path("load"); diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 8452884..ff10174 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -63,9 +63,13 @@ async fn main() -> anyhow::Result<()> { Arc::clone(®istry), Arc::clone(&credential_store_for_providers), )); - if let Err(error) = custom_providers.load().await { - tracing::warn!(%error, "failed to load custom providers"); - } + let startup_notices = match custom_providers.load().await { + Ok(notices) => notices, + Err(error) => { + tracing::warn!(%error, "failed to load custom providers"); + Vec::new() + } + }; let session_manager = Arc::new(SessionManager::new(sessions_path()?)); let resumed_session = load_resumed_session(&session_manager).await?; let model = select_model(®istry, &settings, resumed_session.as_ref())?; @@ -99,6 +103,9 @@ async fn main() -> anyhow::Result<()> { if was_resumed { controller.restore_session_history().await; } + for notice in startup_notices { + controller.push_info(notice); + } // `Runtime::run` consumes the root, so keep the agent for the saved-session // message printed after the TUI exits. From b1dff6ea794b6ff371a07039cece68683cf98d4a Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 11:59:53 +0530 Subject: [PATCH 22/36] Save providers.json before the key, and take a new entry back if its key fails --- crates/alan/src/core/custom_providers.rs | 120 ++++++++++++++++++++--- 1 file changed, 108 insertions(+), 12 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 830bf6e..aa13f8a 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -217,7 +217,15 @@ impl CustomProviders { self.registry.custom(&name.id()).is_none(), "{name} already exists; change it with /provider edit" ); - self.save(name, api, url, key).await + let (entry, models) = self.check(name, api, url, key.as_deref()).await?; + self.store.put(entry.clone()).await?; + if let Err(error) = self.save_key(&entry.name, key).await { + self.store.remove(&entry.name).await?; + return Err(error); + } + let count = models.len(); + self.register(&entry, models)?; + Ok(count) } /// [`add`](Self::add) for a saved provider. No key removes the saved one. @@ -228,7 +236,12 @@ impl CustomProviders { url: &str, key: Option, ) -> Result { - self.save(name, api, url, key).await + let (entry, models) = self.check(name, api, url, key.as_deref()).await?; + self.store.put(entry.clone()).await?; + self.save_key(&entry.name, key).await?; + let count = models.len(); + self.register(&entry, models)?; + Ok(count) } /// Refuses the provider of the running model: its next request would go @@ -244,18 +257,21 @@ impl CustomProviders { Ok(()) } - async fn save( + async fn check( &self, name: ProviderName, api: ApiId, url: &str, - key: Option, - ) -> Result { + key: Option<&str>, + ) -> Result<(CustomProviderEntry, Vec)> { let url = url.trim().trim_end_matches('/').to_owned(); - let models = fetch_catalog(&name.id(), &api, &url, key.as_deref()) + let models = fetch_catalog(&name.id(), &api, &url, key) .await .with_context(|| format!("checking {url}/models"))?; - let count = models.len(); + Ok((CustomProviderEntry { name, url, api }, models)) + } + + async fn save_key(&self, name: &ProviderName, key: Option) -> Result<()> { match key { Some(key) => { self.credentials @@ -264,10 +280,7 @@ impl CustomProviders { } None => self.credentials.delete(&name.id()).await?, } - let entry = CustomProviderEntry { name, url, api }; - self.store.put(entry.clone()).await?; - self.register(&entry, models)?; - Ok(count) + Ok(()) } fn register(&self, entry: &CustomProviderEntry, models: Vec) -> Result<()> { @@ -292,7 +305,7 @@ impl CustomProviders { mod tests { use super::*; use crate::core::store::JsonStore; - use providers::InMemoryCredentialStore; + use providers::{CredentialError, CredentialInfo, InMemoryCredentialStore}; use serde_json::json; use std::env::temp_dir; @@ -587,6 +600,89 @@ mod tests { ); } + #[tokio::test] + async fn add_leaves_no_key_when_providers_json_cannot_be_written() { + // A directory where the file should be: every read and write fails. + let path = store_path("unwritable"); + std::fs::create_dir_all(&path).unwrap(); + let credentials = Arc::new(InMemoryCredentialStore::new()); + let providers = CustomProviders::new( + CustomProviderStore::new(path.clone()), + Arc::new(ProviderRegistry::new([])), + credentials.clone(), + ); + let url = serve_once(OK_TWO_MODELS).await; + + let result = providers + .add( + name("grok"), + ApiId::ChatCompletions, + &url, + Some("sk".into()), + ) + .await; + + assert!(result.is_err()); + assert!( + credentials + .read(&name("grok").id()) + .await + .unwrap() + .is_none() + ); + let _ = std::fs::remove_dir_all(&path); + cleanup("unwritable"); + } + + #[tokio::test] + async fn add_takes_the_entry_back_when_the_key_cannot_be_saved() { + let (providers_json, credentials) = (store_path("key-fails"), Arc::new(FailingKeys)); + let _ = std::fs::remove_file(&providers_json); + let registry = Arc::new(ProviderRegistry::new([])); + let providers = CustomProviders::new( + CustomProviderStore::new(providers_json), + Arc::clone(®istry), + credentials, + ); + let url = serve_once(OK_TWO_MODELS).await; + + let result = providers + .add( + name("grok"), + ApiId::ChatCompletions, + &url, + Some("sk".into()), + ) + .await; + + assert!(result.is_err()); + assert!(providers.entries().await.unwrap().is_empty()); + assert!(registry.custom(&name("grok").id()).is_none()); + cleanup("key-fails"); + } + + /// A key store whose writes always fail. + struct FailingKeys; + + #[async_trait::async_trait] + impl CredentialStore for FailingKeys { + async fn read(&self, _: &ProviderId) -> Result, CredentialError> { + Ok(None) + } + + async fn list(&self) -> Result, CredentialError> { + Ok(Vec::new()) + } + + async fn put(&self, _: &ProviderId, _: Credential) -> Result<(), CredentialError> { + Err(CredentialError::LockBusy) + } + + async fn delete(&self, _: &ProviderId) -> Result<(), CredentialError> { + Ok(()) + } + } + #[tokio::test] async fn update_without_a_key_deletes_the_saved_one() { let (providers, _registry, credentials) = service("update"); From 4696433b93a35a569bc123241e4bee04e2e57a0f Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 12:00:04 +0530 Subject: [PATCH 23/36] Document the built-in and custom provider tracks --- AGENTS.md | 8 +++++--- README.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3a1d42..ac5ad09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,9 +66,11 @@ in with `/login`, or set the provider's API key variable: OPENROUTER_API_KEY=... cargo run -p alan ``` -Providers added with `/provider add` live in `core::custom_providers`: name and -URL in `/providers.json`, key only in `auth.json`, registered as -`providers::CustomProvider`. +`ProviderRegistry` keeps two tracks: the built-ins from `build_providers`, fixed +at startup, and the custom providers added with `/provider add`. Custom +providers live in `core::custom_providers`: name, URL and API standard +(`ApiId`) in `/providers.json`, key only in `auth.json`, registered +as `providers::CustomProvider`. Only `ApiId::ChatCompletions` is implemented. Optional model override: diff --git a/README.md b/README.md index 7acb41f..be30707 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Alan starts in strict policy and remembers the policy you pick across runs. Swit Any server that speaks the Chat Completions API can be a provider: hosted APIs such as Groq, xAI or Mistral, or a model server on your machine such as Ollama or LM Studio. -`/provider add` asks for a name, the base URL (the part before `/chat/completions`, e.g. `https://api.groq.com/openai/v1` or `http://localhost:11434/v1`) and an API key if the server needs one. Alan calls `/models` to check the URL and key, and saves the provider only when that call works. Every model the server lists then appears in `/models`, including ones it adds later. +`/provider add` asks for a name (not one a built-in provider already uses), the base URL (the part before `/chat/completions`, e.g. `https://api.groq.com/openai/v1` or `http://localhost:11434/v1`) and an API key if the server needs one. Alan calls `/models` to check the URL and key, and saves the provider only when that call works. Every model the server lists then appears in `/models`, including ones it adds later. The key is stored in `auth.json` with the other providers' keys, so `/login` can change it too. `/provider edit` changes the URL or key; the name is fixed. `/provider remove` deletes the provider and its key. If one of its models is in use, Alan first asks which model to switch to. From c8a7348abb310f8159554ea796c884cc57b44c18 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 12:01:49 +0530 Subject: [PATCH 24/36] Keep unreadable providers.json entries without letting them block saving others --- crates/alan/src/core/custom_providers.rs | 94 +++++++++++++++++------- 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index aa13f8a..74a7126 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -8,6 +8,7 @@ use providers::{ fetch_catalog, }; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::fmt; use std::path::PathBuf; use std::sync::Arc; @@ -68,6 +69,10 @@ pub struct CustomProviderEntry { } /// `providers.json`: the saved custom providers, in the order they were added. +/// +/// Entries are edited as raw JSON, matched by name, so one Alan cannot read +/// (a hand edit, or an API standard from a newer Alan) is kept and does not +/// block saving the others. pub struct CustomProviderStore { store: JsonStore, } @@ -79,59 +84,59 @@ impl CustomProviderStore { } } + /// The entries Alan can read. pub async fn load(&self) -> Result> { - decode(self.store.get(PROVIDERS_KEY).await?) + Ok(self.load_each().await?.into_iter().flatten().collect()) } - /// Each saved entry on its own, so one that does not parse leaves the - /// rest readable. + /// Each saved entry on its own, with the reason for any it cannot read. pub async fn load_each(&self) -> Result>> { - let Some(value) = self.store.get(PROVIDERS_KEY).await? else { - return Ok(Vec::new()); - }; - let values: Vec = serde_json::from_value(value)?; + let values = raw_entries(self.store.get(PROVIDERS_KEY).await?)?; Ok(values.into_iter().map(serde_json::from_value).collect()) } /// Save `entry`, replacing a saved provider with the same name. pub async fn put(&self, entry: CustomProviderEntry) -> Result<()> { + let name = entry.name.to_string(); + let value = serde_json::to_value(entry)?; self.store - .update(PROVIDERS_KEY, move |value| { - let mut entries = decode(value)?; - match entries.iter_mut().find(|saved| saved.name == entry.name) { - Some(saved) => *saved = entry, - None => entries.push(entry), + .update(PROVIDERS_KEY, move |saved| { + let mut values = raw_entries(saved)?; + match values.iter_mut().find(|saved| has_name(saved, &name)) { + Some(saved) => *saved = value, + None => values.push(value), } - Ok((Some(serde_json::to_value(entries)?), ())) + Ok((Some(Value::Array(values)), ())) }) .await } pub async fn remove(&self, name: &ProviderName) -> Result { + let name = name.to_string(); self.store - .update(PROVIDERS_KEY, |value| { - let mut entries = decode(value)?; - let before = entries.len(); - entries.retain(|saved| saved.name != *name); - let removed = entries.len() < before; - let replacement = if entries.is_empty() { - None - } else { - Some(serde_json::to_value(entries)?) - }; + .update(PROVIDERS_KEY, move |saved| { + let mut values = raw_entries(saved)?; + let before = values.len(); + values.retain(|saved| !has_name(saved, &name)); + let removed = values.len() < before; + let replacement = (!values.is_empty()).then_some(Value::Array(values)); Ok((replacement, removed)) }) .await } } -fn decode(value: Option) -> Result> { +fn raw_entries(value: Option) -> Result> { Ok(value .map(serde_json::from_value) .transpose()? .unwrap_or_default()) } +fn has_name(entry: &Value, name: &str) -> bool { + entry.get("name").and_then(Value::as_str) == Some(name) +} + /// Default location of providers.json inside the Alan data directory. pub fn default_providers_path() -> Result { paths::data_file("providers.json") @@ -493,8 +498,8 @@ mod tests { } #[tokio::test] - async fn store_load_rejects_an_invalid_name_in_the_file() { - let path = temp_path("corrupt"); + async fn store_keeps_entries_it_cannot_read_and_still_saves_others() { + let path = temp_path("unreadable"); JsonStore::new(path.clone()) .set( PROVIDERS_KEY, @@ -502,8 +507,41 @@ mod tests { ) .await .unwrap(); - assert!(CustomProviderStore::new(path).load().await.is_err()); - cleanup("corrupt"); + let store = CustomProviderStore::new(path.clone()); + + store + .put(CustomProviderEntry { + name: name("grok"), + url: "http://one".into(), + api: ApiId::ChatCompletions, + }) + .await + .unwrap(); + assert!(store.remove(&name("grok")).await.unwrap()); + store + .put(CustomProviderEntry { + name: name("groq"), + url: "http://two".into(), + api: ApiId::ChatCompletions, + }) + .await + .unwrap(); + + let names: Vec<_> = store + .load() + .await + .unwrap() + .into_iter() + .map(|e| e.name.to_string()) + .collect(); + assert_eq!(names, ["groq"]); + let raw = JsonStore::new(path) + .get(PROVIDERS_KEY) + .await + .unwrap() + .unwrap(); + assert_eq!(raw.as_array().unwrap().len(), 2, "{raw}"); + cleanup("unreadable"); } #[tokio::test] From 4036e496c5e4a621fb8f2d6e92ca42ff82374613 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 12:02:59 +0530 Subject: [PATCH 25/36] Drop a custom entry a built-in now names, without touching the built-in's key --- crates/alan/src/core/custom_providers.rs | 42 +++++++++++++++++++----- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 74a7126..4c9700e 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -165,7 +165,8 @@ impl CustomProviders { /// Register every saved provider Alan can use; their models are fetched /// the next time `/models` opens. Entries it cannot read or bind are - /// skipped. Returns a notice for each entry a built-in now takes the name of. + /// skipped. Returns a notice for each entry removed because a built-in now + /// takes its name. pub async fn load(&self) -> Result> { let mut notices = Vec::new(); for entry in self.store.load_each().await? { @@ -176,10 +177,13 @@ impl CustomProviders { continue; } }; + // The name, and its key slot in `auth.json`, now belong to the + // built-in, so only the entry goes. if self.registry.is_builtin(&entry.name.id()) { + self.store.remove(&entry.name).await?; notices.push(format!( - "custom provider {} clashes with a built-in; remove it and add it under another name", - entry.name + "custom provider {} ({}) was removed: a built-in provider now uses that name; add it again under another name", + entry.name, entry.url )); continue; } @@ -852,7 +856,7 @@ mod tests { } #[tokio::test] - async fn load_skips_a_name_a_builtin_now_uses_and_says_so() { + async fn load_drops_an_entry_a_builtin_now_names_and_keeps_the_builtins_key() { let path = temp_path("load-clash"); let store = CustomProviderStore::new(path.clone()); for n in ["openai", "grok"] { @@ -865,6 +869,14 @@ mod tests { .await .unwrap(); } + let credentials = Arc::new(InMemoryCredentialStore::new()); + credentials + .put( + &name("openai").id(), + Credential::ApiKey { key: "sk".into() }, + ) + .await + .unwrap(); let openai = CustomProvider::new( name("openai").id(), ApiId::ChatCompletions, @@ -879,7 +891,7 @@ mod tests { let providers = CustomProviders::new( CustomProviderStore::new(path), Arc::clone(®istry), - Arc::new(InMemoryCredentialStore::new()), + credentials.clone(), ); let notices = providers.load().await.unwrap(); @@ -887,13 +899,27 @@ mod tests { assert_eq!( notices, [ - "custom provider openai clashes with a built-in; remove it and add it under another name" + "custom provider openai (http://localhost:1/v1) was removed: a built-in provider now uses that name; add it again under another name" ] ); assert!(registry.custom(&name("openai").id()).is_none()); assert!(registry.custom(&name("grok").id()).is_some()); - // Still listed, so /provider remove can delete it. - assert_eq!(providers.entries().await.unwrap().len(), 2); + let names: Vec<_> = providers + .entries() + .await + .unwrap() + .into_iter() + .map(|e| e.name.to_string()) + .collect(); + assert_eq!(names, ["grok"]); + // The key under that name now belongs to the built-in. + assert!( + credentials + .read(&name("openai").id()) + .await + .unwrap() + .is_some() + ); cleanup("load-clash"); } From 439243ad3f223f6242799ec830536e368c99bdd8 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:23:46 +0530 Subject: [PATCH 26/36] Stop AlanRoot carrying custom providers; ChatView builds them when /provider runs --- crates/alan/src/main.rs | 5 ++--- crates/alan/src/root.rs | 6 +---- .../src/views/components/chat_view/mod.rs | 22 ++++++++++++++----- .../src/views/components/chat_view/models.rs | 14 +++++------- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index ff10174..71fac11 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -58,11 +58,11 @@ async fn main() -> anyhow::Result<()> { let providers = build_providers(&credential_store_for_providers)?; let registry = Arc::new(ProviderRegistry::new(providers)); - let custom_providers = Arc::new(CustomProviders::new( + let custom_providers = CustomProviders::new( CustomProviderStore::new(custom_providers::default_providers_path()?), Arc::clone(®istry), Arc::clone(&credential_store_for_providers), - )); + ); let startup_notices = match custom_providers.load().await { Ok(notices) => notices, Err(error) => { @@ -114,7 +114,6 @@ async fn main() -> anyhow::Result<()> { controller, registry, credential_store, - custom_providers, permission_handler, policy, skill_roots, diff --git a/crates/alan/src/root.rs b/crates/alan/src/root.rs index 1b56126..a70ec21 100644 --- a/crates/alan/src/root.rs +++ b/crates/alan/src/root.rs @@ -21,7 +21,6 @@ use std::path::PathBuf; use crate::core::ImageAttachment; use crate::core::chat::ChatController; -use crate::core::custom_providers::CustomProviders; use crate::core::permissions::Answer; use crate::core::permissions::PermissionHandler; use crate::core::permissions::ToolPolicy; @@ -67,7 +66,6 @@ pub struct PromptSubmission { pub struct AlanRoot { providers: Arc, credentials: Arc, - custom_providers: Arc, /// Tool-permission request handler routed to the chat view. permission_handler: PermissionHandler, /// Tool-permission policy, shared with the permission manager. @@ -88,7 +86,6 @@ impl AlanRoot { chat: ChatController, providers: Arc, credentials: Arc, - custom_providers: Arc, permission_handler: PermissionHandler, policy: ToolPolicy, skill_roots: Vec, @@ -96,7 +93,6 @@ impl AlanRoot { Self { providers, credentials, - custom_providers, permission_handler, policy, skill_roots, @@ -147,7 +143,7 @@ impl Component for AlanRoot { .take() .expect("chat component installed once"), Arc::clone(&self.providers), - Arc::clone(&self.custom_providers), + Arc::clone(&self.credentials), self.permission_handler.clone(), self.policy.clone(), self.skill_roots.clone(), diff --git a/crates/alan/src/views/components/chat_view/mod.rs b/crates/alan/src/views/components/chat_view/mod.rs index a41eded..e58c1cf 100644 --- a/crates/alan/src/views/components/chat_view/mod.rs +++ b/crates/alan/src/views/components/chat_view/mod.rs @@ -13,7 +13,7 @@ mod session; mod steering; use crate::core::chat::ChatController; -use crate::core::custom_providers::CustomProviders; +use crate::core::custom_providers::{self, CustomProviderStore, CustomProviders}; use crate::core::permissions::PermissionHandler; use crate::core::permissions::PermissionRequest; use crate::core::permissions::{Answer, Policy, ToolPolicy}; @@ -30,7 +30,7 @@ use alan_tui::entity::Entity; use alan_tui::{Subscription, SubscriptionEvent}; use crossterm::event::{Event, KeyCode, KeyEventKind}; use futures_util::Stream; -use providers::ProviderRegistry; +use providers::{CredentialStore, ProviderRegistry}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Style; @@ -73,7 +73,7 @@ pub struct ChatView { permission: Option>, providers: Arc, - custom_providers: Arc, + credentials: Arc, permission_handler: PermissionHandler, policy: ToolPolicy, /// Subscription to the permission-request stream. @@ -93,7 +93,7 @@ impl ChatView { pub fn new( controller: ChatController, providers: Arc, - custom_providers: Arc, + credentials: Arc, permission_handler: PermissionHandler, policy: ToolPolicy, skill_roots: Vec, @@ -106,7 +106,7 @@ impl ChatView { pending_permission: None, permission: None, providers, - custom_providers, + credentials, permission_handler, policy, permission_subscription: None, @@ -119,6 +119,18 @@ impl ChatView { } } + /// What `/provider` saves through. It holds only handles, so a fresh one + /// sees the same providers as the one `main.rs` loaded them with. + fn custom_providers(&self) -> Arc { + let path = custom_providers::default_providers_path() + .expect("main.rs resolved the data directory at startup"); + Arc::new(CustomProviders::new( + CustomProviderStore::new(path), + Arc::clone(&self.providers), + Arc::clone(&self.credentials), + )) + } + fn set_tool_policy(&mut self, policy: Policy, cx: &mut Context<'_, Self, AlanAction>) { let policy_changed = self.policy.policy() != policy; cx.spawn( diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 0573243..8167892 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -189,7 +189,7 @@ pub(crate) fn handle_provider_command( let args = SlashCommand::parse_with_args(text).map(|(_, a)| a.trim().to_owned()); match args.as_deref() { Some("add") => { - cx.open_overlay(ProviderOverlay::add(Arc::clone(&view.custom_providers))); + cx.open_overlay(ProviderOverlay::add(view.custom_providers())); } Some("edit") => open_provider_picker(view, cx, ProviderPick::Edit), Some("remove") => open_provider_picker(view, cx, ProviderPick::Remove), @@ -205,7 +205,7 @@ fn open_provider_picker( cx: &mut Context<'_, ChatView, AlanAction>, pick: ProviderPick, ) { - let providers = Arc::clone(&view.custom_providers); + let providers = view.custom_providers(); cx.spawn( async move { providers.entries().await.map_err(super::to_task) }, move |result, view, cx| { @@ -257,7 +257,7 @@ fn edit_provider( cx: &mut Context<'_, ChatView, AlanAction>, entry: CustomProviderEntry, ) { - let providers = Arc::clone(&view.custom_providers); + let providers = view.custom_providers(); cx.spawn( async move { let key = providers.key(&entry.name).await.map_err(super::to_task)?; @@ -265,11 +265,7 @@ fn edit_provider( }, move |result, view, cx| match result { Ok((entry, key)) => { - cx.open_overlay(ProviderOverlay::edit( - Arc::clone(&view.custom_providers), - entry, - key, - )); + cx.open_overlay(ProviderOverlay::edit(view.custom_providers(), entry, key)); } Err(error) => { view.controller @@ -309,7 +305,7 @@ fn remove_idle_provider( name: ProviderName, active: ProviderId, ) { - let providers = Arc::clone(&view.custom_providers); + let providers = view.custom_providers(); cx.spawn( async move { providers From e827919e5b8d0a9fa1ffc15e5754d9871bfb3efb Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:40:22 +0530 Subject: [PATCH 27/36] Call OpenRouter's pinned providers upstream providers, and describe the provider docs as they are --- README.md | 8 ++++---- crates/alan/src/core/custom_providers.rs | 5 +++-- crates/alan/src/views/components/chat_view/models.rs | 3 --- crates/providers/src/provider.rs | 6 +++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index be30707..e902a52 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A minimal coding agent in your terminal. ## Features -- **First-class OpenRouter support** — full model catalog, per-model custom provider pinning (`/model-providers`), and reasoning effort control (`/effort`). +- **First-class OpenRouter support** — full model catalog, per-model upstream provider pinning (`/model-providers`), and reasoning effort control (`/effort`). - **Paste images** — drop a screenshot from your clipboard straight into the conversation and ask about it. - **Compact with focus** — `/summarize-new "focus hint"` summarizes the context and restarts with just what matters. - **Steering** — queue a message mid-run and the agent picks it up without waiting for the response to finish. @@ -77,7 +77,7 @@ Use the `/login` command to sign in to any of the available providers. Any other Chat Completions server can be added with `/provider add` (see [Custom providers](#custom-providers)). -For OpenRouter, you can pin a custom provider for the selected model with `/model-providers`, followed by the provider name (no quotes): +For OpenRouter, you can pin an upstream provider for the selected model with `/model-providers`, followed by the provider name (no quotes): - `/model-providers deepseek` - `/model-providers xiaomi/fp8` @@ -105,7 +105,7 @@ For OpenRouter, you can pin a custom provider for the selected model with `/mode | `/tool-free` | Allow all tool calls without asking | | `/tool-slip` | Approve a command family once, allow its siblings | | `/tool-strict` | Allow only exact commands already approved | -| `/model-providers ` | Pin a custom provider for the current OpenRouter model | +| `/model-providers ` | Pin an upstream provider for the current OpenRouter model | | `/help` | List available commands | | `/quit` | Exit Alan | @@ -140,7 +140,7 @@ Alan stores everything under `~/.alan/` by default: - `settings.json` — your current settings and active-profile marker. - `profiles.json` — saved named model/reasoning/web settings profiles. -- `auth.json` — provider credentials saved by `/login`. +- `auth.json` — provider credentials saved by `/login` and `/provider`. - `providers.json` — providers added with `/provider add`. - `projects/` — per-project permission grants. - `sessions/` — conversation history (append-only JSONL, one file per session). diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 4c9700e..1d7268b 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -1,6 +1,7 @@ //! Providers added with `/provider add`: any server that speaks Chat -//! Completions. Each is a name and a base URL in `providers.json`. Its key, -//! if any, lives in `auth.json` under the same name so `/login` can change it. +//! Completions. Each is a name, a base URL and an API standard in +//! `providers.json`. Its key, if any, lives in `auth.json` under the same +//! name so `/login` can change it. use anyhow::{Context, Result}; use providers::{ diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 8167892..b94fcd6 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -103,8 +103,6 @@ fn open_model_picker( view.controller.set_max_context(max_context); view.controller.set_reasoning_effort(reasoning_effort); view.controller.apply_model_switch(name); - // Only once the switch succeeded: until then the - // old provider's key is the one requests go out with. if let Some(replaced) = replacing { remove_idle_provider(view, cx, replaced, chosen_provider); } @@ -372,7 +370,6 @@ pub(crate) fn server_tools_for_provider( }) } -/// Bind a catalog entry to a live model through its provider. pub(crate) fn bind_model_info( providers: &ProviderRegistry, model_info: &ModelInfo, diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 53e0bb1..f647cff 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -52,9 +52,9 @@ pub trait Provider: Send + Sync { } } -/// Every provider Alan can bind, in two tracks: the built-ins fixed at -/// startup, and the custom providers `/provider` adds and removes while Alan -/// runs. The custom track keeps their type so an edit can reach them. +/// Every provider a model can bind to, in two tracks: the built-ins fixed at +/// construction, and custom providers added and removed at runtime. The +/// custom track keeps their type so an edit can reach them. pub struct ProviderRegistry { builtin: Vec>, custom: RwLock>>, From 3ae24646abc24f2d5b32b702e5d34b0c261e1fba Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:41:12 +0530 Subject: [PATCH 28/36] Say a custom provider's key was rejected, as built-ins do --- crates/providers/src/custom.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index 71abfe4..d0e3b34 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -135,7 +135,12 @@ impl Provider for CustomProvider { fetch_catalog(&self.id, &self.api, &self.base_url(), Some(key)) .await .map(drop) - .map_err(|error| AuthError::Validation(error.to_string())) + .map_err(|error| { + AuthError::Validation(match error { + ProviderError::Fetch(reason) => reason, + other => other.to_string(), + }) + }) } } } @@ -213,6 +218,11 @@ async fn fetch_chat_completions_catalog( }) })?; let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(ProviderError::Fetch(format!( + "API key was rejected (HTTP {status})" + ))); + } if !status.is_success() { return Err(ProviderError::Fetch(format!("HTTP {status}"))); } @@ -522,6 +532,9 @@ mod tests { let result = provider .validate_auth(&AuthResult::ApiKey("bad".into())) .await; - assert!(matches!(result, Err(AuthError::Validation(_)))); + assert!( + matches!(&result, Err(AuthError::Validation(reason)) if reason == "API key was rejected (HTTP 401 Unauthorized)"), + "{result:?}" + ); } } From 4e4573a8718a5a3a415f38510061dd24b5f14790 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:41:15 +0530 Subject: [PATCH 29/36] Keep the key error when taking a new entry back also fails --- crates/alan/src/core/custom_providers.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 1d7268b..e4f24c0 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -230,7 +230,12 @@ impl CustomProviders { let (entry, models) = self.check(name, api, url, key.as_deref()).await?; self.store.put(entry.clone()).await?; if let Err(error) = self.save_key(&entry.name, key).await { - self.store.remove(&entry.name).await?; + if let Err(undo) = self.store.remove(&entry.name).await { + return Err(error.context(format!( + "{} is still in providers.json: {undo:#}", + entry.name + ))); + } return Err(error); } let count = models.len(); From 41dec5631b39ccc3e039bf0b428eb3ab8904eda0 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:41:43 +0530 Subject: [PATCH 30/36] Show empty and failed provider pickers the way profile pickers do --- .../src/views/components/chat_view/mod.rs | 7 -- .../src/views/components/chat_view/models.rs | 82 ++++++++++--------- 2 files changed, 42 insertions(+), 47 deletions(-) diff --git a/crates/alan/src/views/components/chat_view/mod.rs b/crates/alan/src/views/components/chat_view/mod.rs index e58c1cf..8d7a63d 100644 --- a/crates/alan/src/views/components/chat_view/mod.rs +++ b/crates/alan/src/views/components/chat_view/mod.rs @@ -51,13 +51,6 @@ const EDITOR_BOTTOM_PAD: u16 = 1; /// Fixed-rate repaint interval while an agent stream is in flight. const STREAM_REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(32); -/// Action chosen from the custom-provider picker. -#[derive(Clone, Copy)] -pub(crate) enum ProviderPick { - Edit, - Remove, -} - #[derive(Debug, Clone, Copy)] pub struct LoginRequested; diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index b94fcd6..80e1b78 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -12,7 +12,6 @@ use providers::{ }; use std::sync::Arc; -use super::ProviderPick; use crate::views::components::{SearchListEvent, SearchListOverlay}; use super::{ChatView, parse_provider_order, persist_provider_order}; @@ -203,53 +202,56 @@ fn open_provider_picker( cx: &mut Context<'_, ChatView, AlanAction>, pick: ProviderPick, ) { + let title = match pick { + ProviderPick::Edit => "Edit Provider", + ProviderPick::Remove => "Remove Provider", + }; let providers = view.custom_providers(); cx.spawn( async move { providers.entries().await.map_err(super::to_task) }, - move |result, view, cx| { - let entries = match result { - Ok(entries) if entries.is_empty() => { - view.controller - .push_info("no providers yet; add one with /provider add".to_owned()); - cx.notify(); - return; - } - Ok(entries) => entries, - Err(error) => { - view.controller - .push_info(format!("failed to read providers: {error}")); - cx.notify(); - return; + move |result, _view, cx| match result { + Ok(entries) => { + let labels = entries + .iter() + .map(|entry| format!("{} — {}", entry.name, entry.url)) + .collect(); + let mut picker = SearchListOverlay::new(title, labels); + if entries.is_empty() { + picker.set_empty_message("No saved providers"); } - }; - let title = match pick { - ProviderPick::Edit => "Edit Provider", - ProviderPick::Remove => "Remove Provider", - }; - let labels = entries - .iter() - .map(|entry| format!("{} — {}", entry.name, entry.url)) - .collect(); - let picker = cx.open_overlay(SearchListOverlay::new(title, labels)); - cx.subscribe_once::( - picker, - move |event, view, _picker, cx| { - let SearchListEvent::Chosen(index) = event else { - return; - }; - let Some(entry) = entries.get(*index).cloned() else { - return; - }; - match pick { - ProviderPick::Edit => edit_provider(view, cx, entry), - ProviderPick::Remove => remove_provider(view, cx, entry.name), - } - }, - ); + let picker = cx.open_overlay(picker); + cx.subscribe_once::( + picker, + move |event, view, _picker, cx| { + let SearchListEvent::Chosen(index) = event else { + return; + }; + let Some(entry) = entries.get(*index).cloned() else { + return; + }; + match pick { + ProviderPick::Edit => edit_provider(view, cx, entry), + ProviderPick::Remove => remove_provider(view, cx, entry.name), + } + }, + ); + } + Err(error) => { + let mut picker = SearchListOverlay::new(title, Vec::new()); + picker.set_empty_message(format!("unable to load providers: {error}")); + cx.open_overlay(picker); + } }, ); } +/// Action chosen from the custom-provider picker. +#[derive(Clone, Copy)] +enum ProviderPick { + Edit, + Remove, +} + fn edit_provider( view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>, From c531d5bf063ffa17340b2fff3a5b84b0f09bb557 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:42:25 +0530 Subject: [PATCH 31/36] Delete code nothing reaches: NoAuth, ModelNotFound, unread results and an in-use check callers already make --- crates/alan/src/core/custom_providers.rs | 58 ++----------------- .../src/views/components/chat_view/models.rs | 11 +--- crates/providers/src/auth.rs | 9 --- crates/providers/src/lib.rs | 4 +- crates/providers/src/provider.rs | 19 +++--- 5 files changed, 18 insertions(+), 83 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index e4f24c0..75a07c2 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -112,16 +112,13 @@ impl CustomProviderStore { .await } - pub async fn remove(&self, name: &ProviderName) -> Result { + pub async fn remove(&self, name: &ProviderName) -> Result<()> { let name = name.to_string(); self.store .update(PROVIDERS_KEY, move |saved| { let mut values = raw_entries(saved)?; - let before = values.len(); values.retain(|saved| !has_name(saved, &name)); - let removed = values.len() < before; - let replacement = (!values.is_empty()).then_some(Value::Array(values)); - Ok((replacement, removed)) + Ok(((!values.is_empty()).then_some(Value::Array(values)), ())) }) .await } @@ -259,13 +256,7 @@ impl CustomProviders { Ok(count) } - /// Refuses the provider of the running model: its next request would go - /// out with the key this deletes. - pub async fn remove(&self, name: &ProviderName, active: &ProviderId) -> Result<()> { - anyhow::ensure!( - name.id() != *active, - "{name} is in use; pick another model with /models first" - ); + pub async fn remove(&self, name: &ProviderName) -> Result<()> { self.store.remove(name).await?; self.credentials.delete(&name.id()).await?; self.registry.remove_custom(&name.id()); @@ -490,23 +481,6 @@ mod tests { cleanup("put"); } - #[tokio::test] - async fn store_remove_reports_whether_an_entry_went() { - let store = CustomProviderStore::new(temp_path("remove")); - store - .put(CustomProviderEntry { - name: name("a"), - url: "http://one".into(), - api: ApiId::ChatCompletions, - }) - .await - .unwrap(); - assert!(store.remove(&name("a")).await.unwrap()); - assert!(!store.remove(&name("a")).await.unwrap()); - assert!(store.load().await.unwrap().is_empty()); - cleanup("remove"); - } - #[tokio::test] async fn store_keeps_entries_it_cannot_read_and_still_saves_others() { let path = temp_path("unreadable"); @@ -527,7 +501,7 @@ mod tests { }) .await .unwrap(); - assert!(store.remove(&name("grok")).await.unwrap()); + store.remove(&name("grok")).await.unwrap(); store .put(CustomProviderEntry { name: name("groq"), @@ -801,10 +775,7 @@ mod tests { .await .unwrap(); - providers - .remove(&name("grok"), &ProviderId::new("openrouter")) - .await - .unwrap(); + providers.remove(&name("grok")).await.unwrap(); assert!(providers.entries().await.unwrap().is_empty()); assert!(providers.key(&name("grok")).await.unwrap().is_none()); @@ -812,25 +783,6 @@ mod tests { cleanup("remove-all"); } - #[tokio::test] - async fn remove_refuses_the_provider_in_use() { - let (providers, _registry, _credentials) = service("remove-active"); - let url = serve_once(OK_TWO_MODELS).await; - providers - .add(name("grok"), ApiId::ChatCompletions, &url, None) - .await - .unwrap(); - - let error = providers - .remove(&name("grok"), &name("grok").id()) - .await - .unwrap_err(); - - assert!(error.to_string().contains("in use"), "{error:#}"); - assert_eq!(providers.entries().await.unwrap().len(), 1); - cleanup("remove-active"); - } - #[tokio::test] async fn load_skips_entries_it_cannot_use_and_keeps_the_rest() { let path = temp_path("load-skips"); diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 80e1b78..b28ab1f 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -73,7 +73,6 @@ fn open_model_picker( let agent = view.controller.agent(); let providers = Arc::clone(&providers); let replacing = replacing.clone(); - let chosen_provider = model_info.provider.clone(); let _ = cx.spawn( async move { let mut options = agent.model_options().await; @@ -103,7 +102,7 @@ fn open_model_picker( view.controller.set_reasoning_effort(reasoning_effort); view.controller.apply_model_switch(name); if let Some(replaced) = replacing { - remove_idle_provider(view, cx, replaced, chosen_provider); + remove_idle_provider(view, cx, replaced); } } Err(e) => view.controller.apply_model_switch_failed(e.to_string()), @@ -293,7 +292,7 @@ fn remove_provider( if active == name.id() { open_model_picker(view, cx, format!("Replace {name} with"), Some(name)); } else { - remove_idle_provider(view, cx, name, active); + remove_idle_provider(view, cx, name); } }, ); @@ -303,15 +302,11 @@ fn remove_idle_provider( view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>, name: ProviderName, - active: ProviderId, ) { let providers = view.custom_providers(); cx.spawn( async move { - providers - .remove(&name, &active) - .await - .map_err(super::to_task)?; + providers.remove(&name).await.map_err(super::to_task)?; Ok(name) }, |result, view, cx| { diff --git a/crates/providers/src/auth.rs b/crates/providers/src/auth.rs index 6ace10a..d0cbb69 100644 --- a/crates/providers/src/auth.rs +++ b/crates/providers/src/auth.rs @@ -52,15 +52,6 @@ impl AuthResolver for ApiKeyAuth { } } -pub struct NoAuth; - -#[async_trait] -impl AuthResolver for NoAuth { - async fn resolve(&self) -> Result { - Ok(RequestCredential::None) - } -} - pub struct CredentialAuth { provider: ProviderId, store: Arc, diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index e855456..b5c941b 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -12,9 +12,7 @@ mod zai; pub use custom::{CustomProvider, fetch_catalog}; pub use deepseek::{DeepSeekBuilder, DeepSeekProvider}; -pub use auth::{ - ApiKeyAuth, AuthError, AuthMethod, AuthResolver, AuthResult, CredentialAuth, NoAuth, -}; +pub use auth::{ApiKeyAuth, AuthError, AuthMethod, AuthResolver, AuthResult, CredentialAuth}; pub use catalog::{ApiId, ModelCapabilities, ModelInfo, ModelPricing, ProviderId, ServerToolInfo}; pub use credentials::{ Credential, CredentialError, CredentialInfo, CredentialKind, CredentialStore, diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index f647cff..1fcc4f0 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -11,8 +11,6 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum ProviderError { - #[error("model not found: {0}")] - ModelNotFound(String), #[error("API is not configured for model {model}")] MissingApi { model: String }, #[error("authentication is not configured")] @@ -110,11 +108,11 @@ impl ProviderRegistry { .push(provider); } - pub fn remove_custom(&self, id: &ProviderId) -> bool { - let mut custom = self.custom.write().unwrap_or_else(|e| e.into_inner()); - let before = custom.len(); - custom.retain(|provider| provider.id() != *id); - custom.len() < before + pub fn remove_custom(&self, id: &ProviderId) { + self.custom + .write() + .unwrap_or_else(|e| e.into_inner()) + .retain(|provider| provider.id() != *id); } } @@ -203,9 +201,10 @@ mod tests { let registry = ProviderRegistry::new([builtin("openrouter")]); registry.add_custom(custom("grok", "grok-4")); - assert!(registry.remove_custom(&ProviderId::new("grok"))); - assert!(!registry.remove_custom(&ProviderId::new("grok"))); - assert!(!registry.remove_custom(&ProviderId::new("openrouter"))); + registry.remove_custom(&ProviderId::new("grok")); + registry.remove_custom(&ProviderId::new("openrouter")); + + assert!(registry.get(&ProviderId::new("grok")).is_none()); assert!(registry.get(&ProviderId::new("openrouter")).is_some()); } } From 23c8724df2c55cc2b372eed1dd9bff6531964994 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:44:10 +0530 Subject: [PATCH 32/36] Drop tests another test already covers and share one fake server and provider builder --- crates/alan/src/core/custom_providers.rs | 163 ++++++------------ .../src/views/components/chat_view/models.rs | 20 +-- crates/providers/src/custom.rs | 34 +--- crates/providers/src/provider.rs | 13 +- 4 files changed, 65 insertions(+), 165 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 75a07c2..edc9091 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -308,7 +308,7 @@ impl CustomProviders { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::core::store::JsonStore; use providers::{CredentialError, CredentialInfo, InMemoryCredentialStore}; @@ -318,22 +318,17 @@ mod tests { const OK_TWO_MODELS: &str = "HTTP/1.1 200 OK\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"},{\"id\":\"m2\"}]}"; - fn store_path(name: &str) -> PathBuf { - temp_dir().join(format!( + fn temp_path(name: &str) -> PathBuf { + let path = temp_dir().join(format!( "alan-custom-providers-test-{}-{name}.json", std::process::id() - )) - } - - fn temp_path(name: &str) -> PathBuf { - let path = store_path(name); + )); let _ = std::fs::remove_file(&path); path } fn cleanup(name: &str) { - let path = store_path(name); - let _ = std::fs::remove_file(&path); + let path = temp_path(name); let _ = std::fs::remove_file(format!("{}.lock", path.display())); } @@ -341,6 +336,23 @@ mod tests { ProviderName::parse(value).unwrap() } + pub(crate) fn provider(id: &str, models: &[&str]) -> Arc { + let models = models + .iter() + .map(|model| ModelInfo::new(model, ProviderId::new(id))) + .collect(); + Arc::new( + CustomProvider::new( + ProviderId::new(id), + ApiId::ChatCompletions, + "http://localhost:1/v1", + Arc::new(InMemoryCredentialStore::new()), + models, + ) + .unwrap(), + ) + } + fn service( file: &str, ) -> ( @@ -358,29 +370,11 @@ mod tests { (providers, registry, credentials) } - // one-shot server: replies `response`. - async fn serve_once(response: &'static str) -> String { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let address = listener.local_addr().unwrap(); - 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(); - }); - format!("http://{address}/v1/") - } - - // Replies `OK_TWO_MODELS` to `count` connections, hands back each request. - async fn serve_many(count: usize) -> (String, tokio::task::JoinHandle>) { + // Replies `response` to `count` connections, hands back each request. + async fn serve( + response: &'static str, + count: usize, + ) -> (String, tokio::task::JoinHandle>) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) .await @@ -397,7 +391,7 @@ mod tests { assert!(read > 0); request.extend_from_slice(&buffer[..read]); } - socket.write_all(OK_TWO_MODELS.as_bytes()).await.unwrap(); + socket.write_all(response.as_bytes()).await.unwrap(); requests.push(String::from_utf8(request).unwrap()); } requests @@ -419,16 +413,8 @@ mod tests { #[tokio::test] async fn add_refuses_a_builtin_name_before_any_request() { - let deepseek = CustomProvider::new( - name("deepseek").id(), - ApiId::ChatCompletions, - "http://localhost:1/v1", - Arc::new(InMemoryCredentialStore::new()), - Vec::new(), - ) - .unwrap(); let registry = Arc::new(ProviderRegistry::new([ - Arc::new(deepseek) as Arc + provider("deepseek", &[]) as Arc ])); let providers = CustomProviders::new( CustomProviderStore::new(temp_path("builtin")), @@ -530,8 +516,8 @@ mod tests { #[tokio::test] async fn add_checks_then_saves_entry_key_and_provider() { - let (providers, registry, credentials) = service("add"); - let url = serve_once(OK_TWO_MODELS).await; + let (providers, registry, _credentials) = service("add"); + let url = serve(OK_TWO_MODELS, 1).await.0; let count = providers .add( @@ -551,13 +537,6 @@ mod tests { providers.key(&name("grok")).await.unwrap().as_deref(), Some("sk") ); - assert!( - credentials - .read(&name("grok").id()) - .await - .unwrap() - .is_some() - ); let provider = registry.get(&name("grok").id()).unwrap(); assert_eq!(provider.models().len(), 2); cleanup("add"); @@ -566,7 +545,12 @@ mod tests { #[tokio::test] async fn add_saves_nothing_when_the_check_fails() { let (providers, registry, credentials) = service("add-fails"); - let url = serve_once("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}").await; + let url = serve( + "HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}", + 1, + ) + .await + .0; let error = providers .add( @@ -593,17 +577,8 @@ mod tests { #[tokio::test] async fn add_refuses_a_taken_name_before_any_request() { - let (providers, registry, credentials) = service("taken"); - registry.add_custom(Arc::new( - CustomProvider::new( - name("taken").id(), - ApiId::ChatCompletions, - "http://localhost:1/v1", - credentials, - Vec::new(), - ) - .unwrap(), - )); + let (providers, registry, _credentials) = service("taken"); + registry.add_custom(provider("taken", &[])); // Port 1 is closed: reaching the network would say "could not connect". let error = providers @@ -625,7 +600,7 @@ mod tests { #[tokio::test] async fn add_leaves_no_key_when_providers_json_cannot_be_written() { // A directory where the file should be: every read and write fails. - let path = store_path("unwritable"); + let path = temp_path("unwritable"); std::fs::create_dir_all(&path).unwrap(); let credentials = Arc::new(InMemoryCredentialStore::new()); let providers = CustomProviders::new( @@ -633,7 +608,7 @@ mod tests { Arc::new(ProviderRegistry::new([])), credentials.clone(), ); - let url = serve_once(OK_TWO_MODELS).await; + let url = serve(OK_TWO_MODELS, 1).await.0; let result = providers .add( @@ -658,15 +633,14 @@ mod tests { #[tokio::test] async fn add_takes_the_entry_back_when_the_key_cannot_be_saved() { - let (providers_json, credentials) = (store_path("key-fails"), Arc::new(FailingKeys)); - let _ = std::fs::remove_file(&providers_json); + let (providers_json, credentials) = (temp_path("key-fails"), Arc::new(FailingKeys)); let registry = Arc::new(ProviderRegistry::new([])); let providers = CustomProviders::new( CustomProviderStore::new(providers_json), Arc::clone(®istry), credentials, ); - let url = serve_once(OK_TWO_MODELS).await; + let url = serve(OK_TWO_MODELS, 1).await.0; let result = providers .add( @@ -712,7 +686,7 @@ mod tests { .put(&name("grok").id(), Credential::ApiKey { key: "old".into() }) .await .unwrap(); - let url = serve_once(OK_TWO_MODELS).await; + let url = serve(OK_TWO_MODELS, 1).await.0; providers .update(name("grok"), ApiId::ChatCompletions, &url, None) @@ -726,7 +700,7 @@ mod tests { #[tokio::test] async fn update_moves_models_already_bound_to_the_new_url() { let (providers, registry, _credentials) = service("edit-bound"); - let old_url = serve_once(OK_TWO_MODELS).await; + let old_url = serve(OK_TWO_MODELS, 1).await.0; providers .add(name("grok"), ApiId::ChatCompletions, &old_url, None) .await @@ -735,7 +709,7 @@ mod tests { let model = providers::bind_model(provider.as_ref(), "m1", Default::default()).unwrap(); // The new server answers the edit's check, then the chat request. - let (new_url, server) = serve_many(2).await; + let (new_url, server) = serve(OK_TWO_MODELS, 2).await; providers .update(name("grok"), ApiId::ChatCompletions, &new_url, None) .await @@ -764,7 +738,7 @@ mod tests { #[tokio::test] async fn remove_deletes_entry_key_and_provider() { let (providers, registry, _credentials) = service("remove-all"); - let url = serve_once(OK_TWO_MODELS).await; + let url = serve(OK_TWO_MODELS, 1).await.0; providers .add( name("grok"), @@ -810,6 +784,14 @@ mod tests { assert!(notices.is_empty(), "{notices:?}"); let ids: Vec<_> = registry.providers().iter().map(|p| p.id().0).collect(); assert_eq!(ids, ["grok"]); + // Models come with the first `/models`, not at startup. + assert!( + registry + .get(&name("grok").id()) + .unwrap() + .models() + .is_empty() + ); cleanup("load-skips"); } @@ -835,16 +817,8 @@ mod tests { ) .await .unwrap(); - let openai = CustomProvider::new( - name("openai").id(), - ApiId::ChatCompletions, - "http://localhost:1/v1", - Arc::new(InMemoryCredentialStore::new()), - Vec::new(), - ) - .unwrap(); let registry = Arc::new(ProviderRegistry::new([ - Arc::new(openai) as Arc + provider("openai", &[]) as Arc ])); let providers = CustomProviders::new( CustomProviderStore::new(path), @@ -880,29 +854,4 @@ mod tests { ); cleanup("load-clash"); } - - #[tokio::test] - async fn load_registers_saved_providers_without_fetching() { - let path = temp_path("load"); - CustomProviderStore::new(path.clone()) - .put(CustomProviderEntry { - name: name("grok"), - url: "http://localhost:1/v1".into(), - api: ApiId::ChatCompletions, - }) - .await - .unwrap(); - let registry = Arc::new(ProviderRegistry::new([])); - let providers = CustomProviders::new( - CustomProviderStore::new(path), - Arc::clone(®istry), - Arc::new(InMemoryCredentialStore::new()), - ); - - providers.load().await.unwrap(); - - let provider = registry.get(&name("grok").id()).unwrap(); - assert!(provider.models().is_empty()); - cleanup("load"); - } } diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index b28ab1f..4754972 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -390,25 +390,13 @@ pub(crate) async fn persist_model(model_id: &str, provider_id: &ProviderId) -> a #[cfg(test)] mod tests { use super::*; - use providers::{CustomProvider, InMemoryCredentialStore, Provider}; - - fn provider(name: &str, model: &str) -> Arc { - Arc::new( - CustomProvider::new( - ProviderId::new(name), - providers::ApiId::ChatCompletions, - "http://localhost:11434/v1", - Arc::new(InMemoryCredentialStore::new()), - vec![ModelInfo::new(model, ProviderId::new(name))], - ) - .unwrap(), - ) - } + use crate::core::custom_providers::tests::provider; #[test] fn picker_models_hides_only_the_replaced_provider() { - let registry = - ProviderRegistry::new([provider("grok", "grok-4"), provider("groq", "llama")]); + let registry = ProviderRegistry::new([]); + registry.add_custom(provider("grok", &["grok-4"])); + registry.add_custom(provider("groq", &["llama"])); let all: Vec<_> = picker_models(®istry, None) .into_iter() diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index d0e3b34..3621aba 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -407,22 +407,6 @@ mod tests { )); } - #[tokio::test] - async fn fetch_catalog_sends_key_to_models_path() { - let (url, server) = serve_once(TWO_MODELS).await; - let models = fetch_catalog(&id(), &ApiId::ChatCompletions, &url, Some("sk-test")) - .await - .unwrap(); - assert_eq!(models.len(), 2); - 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 fetch_catalog_sends_no_auth_without_key() { let (url, server) = serve_once(TWO_MODELS).await; @@ -438,18 +422,6 @@ mod tests { ); } - #[tokio::test] - async fn fetch_catalog_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 = fetch_catalog(&id(), &ApiId::ChatCompletions, &url, Some("bad")) - .await - .unwrap_err(); - assert!(error.to_string().contains("401"), "{error}"); - } - #[tokio::test] async fn fetch_catalog_reports_unreachable() { // bind then drop: port is closed. @@ -494,10 +466,10 @@ mod tests { .unwrap(); provider.fetch_models().await.unwrap(); assert_eq!(provider.models().len(), 2); + let request = server.await.unwrap(); + assert!(request.starts_with("GET /v1/models HTTP/1.1"), "{request}"); assert!( - server - .await - .unwrap() + request .to_lowercase() .contains("authorization: bearer sk-stored") ); diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 1fcc4f0..5ae252a 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -175,7 +175,7 @@ mod tests { } #[test] - fn get_finds_both_tracks_and_is_builtin_only_the_first() { + fn each_lookup_sees_the_right_track() { let registry = ProviderRegistry::new([builtin("openrouter")]); registry.add_custom(custom("grok", "grok-4")); @@ -183,16 +183,7 @@ mod tests { assert!(registry.get(&ProviderId::new("grok")).is_some()); assert!(registry.is_builtin(&ProviderId::new("openrouter"))); assert!(!registry.is_builtin(&ProviderId::new("grok"))); - } - - #[test] - fn custom_returns_the_typed_provider_from_the_custom_track_only() { - let registry = ProviderRegistry::new([builtin("openrouter")]); - registry.add_custom(custom("grok", "grok-4")); - - let grok = registry.custom(&ProviderId::new("grok")).unwrap(); - - assert_eq!(grok.models()[0].id, "grok-4"); + assert!(registry.custom(&ProviderId::new("grok")).is_some()); assert!(registry.custom(&ProviderId::new("openrouter")).is_none()); } From 5a4170a2697e46492bb956c61ff7fb8724bd310c Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 13:45:21 +0530 Subject: [PATCH 33/36] Shorten the provider code: one form constructor, fewer derives and helpers --- crates/alan/src/core/custom_providers.rs | 22 +++------ crates/alan/src/main.rs | 11 ++--- crates/alan/src/provider_overlay.rs | 62 ++++++++---------------- crates/providers/src/custom.rs | 16 +++--- 4 files changed, 38 insertions(+), 73 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index edc9091..680721d 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -21,8 +21,8 @@ const PROVIDERS_KEY: &str = "providers"; /// A custom provider's name, and its id in `/models`, `auth.json`, /// `settings.json` and `ALAN_PROVIDER`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(try_from = "String")] pub struct ProviderName(String); impl ProviderName { @@ -50,19 +50,13 @@ impl TryFrom for ProviderName { } } -impl From for String { - fn from(name: ProviderName) -> Self { - name.0 - } -} - impl fmt::Display for ProviderName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct CustomProviderEntry { pub name: ProviderName, pub url: String, @@ -168,12 +162,10 @@ impl CustomProviders { pub async fn load(&self) -> Result> { let mut notices = Vec::new(); for entry in self.store.load_each().await? { - let entry = match entry { - Ok(entry) => entry, - Err(error) => { - tracing::warn!(%error, "skipping an unreadable entry in providers.json"); - continue; - } + let Ok(entry) = entry.inspect_err(|error| { + tracing::warn!(%error, "skipping an unreadable entry in providers.json"); + }) else { + continue; }; // The name, and its key slot in `auth.json`, now belong to the // built-in, so only the entry goes. diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 71fac11..277fa08 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -63,13 +63,10 @@ async fn main() -> anyhow::Result<()> { Arc::clone(®istry), Arc::clone(&credential_store_for_providers), ); - let startup_notices = match custom_providers.load().await { - Ok(notices) => notices, - Err(error) => { - tracing::warn!(%error, "failed to load custom providers"); - Vec::new() - } - }; + let startup_notices = custom_providers.load().await.unwrap_or_else(|error| { + tracing::warn!(%error, "failed to load custom providers"); + Vec::new() + }); let session_manager = Arc::new(SessionManager::new(sessions_path()?)); let resumed_session = load_resumed_session(&session_manager).await?; let model = select_model(®istry, &settings, resumed_session.as_ref())?; diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs index f2a1d4b..df7dc38 100644 --- a/crates/alan/src/provider_overlay.rs +++ b/crates/alan/src/provider_overlay.rs @@ -28,7 +28,7 @@ enum Mode { Edit, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Default)] struct FormFields { name: String, url: String, @@ -36,15 +36,6 @@ struct FormFields { } impl FormFields { - fn field(&self, index: usize) -> &str { - match index { - NAME => &self.name, - URL => &self.url, - API_KEY => &self.api_key, - _ => "", - } - } - fn submission(&self) -> anyhow::Result { let name = ProviderName::parse(&self.name)?; let url = self.url.trim(); @@ -59,14 +50,13 @@ impl FormFields { } /// What the form sends once its fields parse. -#[derive(Debug)] struct Submission { name: ProviderName, url: String, key: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] enum OverlayState { Editing, Checking, @@ -95,13 +85,14 @@ pub struct ProviderOverlay { impl ProviderOverlay { pub fn add(providers: Arc) -> Self { - Self::new( + Self { providers, - Mode::Add, - ApiId::ChatCompletions, - FormFields::default(), - NAME, - ) + mode: Mode::Add, + api: ApiId::ChatCompletions, + fields: FormFields::default(), + focused: NAME, + state: OverlayState::Editing, + } } /// The name is fixed: `auth.json`, `settings.json` and profiles refer to @@ -110,29 +101,17 @@ impl ProviderOverlay { providers: Arc, entry: CustomProviderEntry, key: Option, - ) -> Self { - let fields = FormFields { - name: entry.name.to_string(), - url: entry.url, - api_key: key.unwrap_or_default(), - }; - Self::new(providers, Mode::Edit, entry.api, fields, URL) - } - - fn new( - providers: Arc, - mode: Mode, - api: ApiId, - fields: FormFields, - focused: usize, ) -> Self { Self { - providers, - mode, - api, - fields, - focused, - state: OverlayState::Editing, + mode: Mode::Edit, + api: entry.api, + fields: FormFields { + name: entry.name.to_string(), + url: entry.url, + api_key: key.unwrap_or_default(), + }, + focused: URL, + ..Self::add(providers) } } @@ -375,12 +354,13 @@ fn render_fields( } else { Style::default().fg(theme::EDITOR_FG) }; + let text = if i == NAME { &fields.name } else { &fields.url }; let value = match i { API => Span::styled("Chat Completions", value_style), API_KEY if fields.api_key.is_empty() => Span::styled("optional", muted), API_KEY => Span::styled("•".repeat(fields.api_key.chars().count()), value_style), - _ if fields.field(i).is_empty() => Span::styled("required", muted), - _ => Span::styled(fields.field(i), value_style), + _ if text.is_empty() => Span::styled("required", muted), + _ => Span::styled(text.as_str(), value_style), }; let mut spans = vec![ Span::styled(if is_focused { " › " } else { " " }, accent), diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index 3621aba..f7017d2 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -72,10 +72,7 @@ impl CustomProvider { } fn base_url(&self) -> String { - self.base_url - .read() - .unwrap_or_else(|e| e.into_inner()) - .clone() + current(&self.base_url) } } @@ -89,12 +86,7 @@ struct LiveUrlApi { #[async_trait] impl LlmApi for LiveUrlApi { async fn stream(&self, request: LlmRequest<'_>) -> Result { - let base_url = self - .base_url - .read() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - ChatCompletionsApi::new(base_url, Arc::clone(&self.http)) + ChatCompletionsApi::new(current(&self.base_url), Arc::clone(&self.http)) .stream(request) .await } @@ -162,6 +154,10 @@ impl Provider for CustomProvider { } } +fn current(base_url: &RwLock) -> String { + base_url.read().unwrap_or_else(|e| e.into_inner()).clone() +} + /// Reads the provider's key from the credential store. Without one, requests /// carry no `Authorization` header, which keyless local servers expect. struct StoredKeyAuth { From 27c253d556a59fda76aa87e06c795041f66c9051 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 14:12:18 +0530 Subject: [PATCH 34/36] Say what failed and what to check when a provider's URL or key does not work --- crates/alan/src/core/custom_providers.rs | 23 +++-- crates/alan/src/provider_overlay.rs | 5 +- crates/providers/src/custom.rs | 109 ++++++++++++++++------- 3 files changed, 98 insertions(+), 39 deletions(-) diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 680721d..7906f0b 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -3,10 +3,10 @@ //! `providers.json`. Its key, if any, lives in `auth.json` under the same //! name so `/login` can change it. -use anyhow::{Context, Result}; +use anyhow::Result; use providers::{ - ApiId, Credential, CredentialStore, CustomProvider, ModelInfo, ProviderId, ProviderRegistry, - fetch_catalog, + ApiId, Credential, CredentialStore, CustomProvider, ModelInfo, ProviderError, ProviderId, + ProviderRegistry, fetch_catalog, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -263,9 +263,15 @@ impl CustomProviders { key: Option<&str>, ) -> Result<(CustomProviderEntry, Vec)> { let url = url.trim().trim_end_matches('/').to_owned(); - let models = fetch_catalog(&name.id(), &api, &url, key) - .await - .with_context(|| format!("checking {url}/models"))?; + // The reason already names the URL; the error's own prefix would + // only repeat that the check failed. + let models = + fetch_catalog(&name.id(), &api, &url, key) + .await + .map_err(|error| match error { + ProviderError::Fetch(reason) => anyhow::anyhow!(reason), + other => other.into(), + })?; Ok((CustomProviderEntry { name, url, api }, models)) } @@ -554,7 +560,10 @@ pub(crate) mod tests { .await .unwrap_err(); - assert!(format!("{error:#}").contains("401"), "{error:#}"); + assert_eq!( + format!("{error:#}"), + "the API key was rejected (HTTP 401 Unauthorized)" + ); assert!(providers.entries().await.unwrap().is_empty()); assert!( credentials diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs index df7dc38..988ef7a 100644 --- a/crates/alan/src/provider_overlay.rs +++ b/crates/alan/src/provider_overlay.rs @@ -480,7 +480,8 @@ mod tests { fn an_error_goes_back_to_the_form_with_fields_kept() { let mut overlay = ProviderOverlay::add(providers()); type_text(&mut overlay, "grok"); - overlay.state = OverlayState::Error("checking …: HTTP 401".into()); + overlay.state = + OverlayState::Error("the API key was rejected (HTTP 401 Unauthorized)".into()); assert_eq!(press(&mut overlay, KeyCode::Enter), KeyOutcome::Redraw); assert_eq!(overlay.state, OverlayState::Editing); @@ -554,7 +555,7 @@ mod tests { fn an_error_shows_the_reason() { let screen = screen( &FormFields::default(), - &OverlayState::Error("checking http://x/v1/models: HTTP 401".into()), + &OverlayState::Error("the API key was rejected (HTTP 401 Unauthorized)".into()), ); assert!(screen.contains("HTTP 401")); } diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index f7017d2..f763cf4 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -195,8 +195,10 @@ async fn fetch_chat_completions_catalog( base_url: &str, api_key: Option<&str>, ) -> Result, ProviderError> { + let base = base_url.trim().trim_end_matches('/'); + let url = format!("{base}/models"); let mut request = reqwest::Client::new() - .get(format!("{}/models", base_url.trim().trim_end_matches('/'))) + .get(&url) .timeout(std::time::Duration::from_secs(10)); if let Some(key) = api_key.filter(|key| !key.is_empty()) { request = request.bearer_auth(key); @@ -204,39 +206,57 @@ async fn fetch_chat_completions_catalog( // 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() + format!("{base} did not answer within 10 seconds") } else if error.is_connect() { - "could not connect".into() + format!("could not reach {base}; is the server running?") } else if error.is_builder() { - "invalid URL".into() + format!("\"{base}\" is not a valid URL; it should look like http://localhost:11434/v1") } else { - format!("request failed: {error}") + format!("could not reach {base}: {error}") }) })?; let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err(ProviderError::Fetch(format!( - "API key was rejected (HTTP {status})" - ))); - } - if !status.is_success() { - return Err(ProviderError::Fetch(format!("HTTP {status}"))); + match status { + reqwest::StatusCode::UNAUTHORIZED => { + return Err(ProviderError::Fetch(format!( + "the API key was rejected (HTTP {status})" + ))); + } + // The usual mistake: a base URL without the `/v1` the API lives under. + reqwest::StatusCode::NOT_FOUND => { + return Err(ProviderError::Fetch(format!( + "{url} was not found; the URL should end where /chat/completions would start, e.g. …/v1" + ))); + } + status if !status.is_success() => { + return Err(ProviderError::Fetch(format!( + "{url} answered HTTP {status}" + ))); + } + _ => {} } let payload = response .text() .await - .map_err(|error| ProviderError::Fetch(format!("request failed: {error}")))?; - parse_catalog(provider, &payload) + .map_err(|error| ProviderError::Fetch(format!("could not read {url}: {error}")))?; + parse_catalog(provider, base, &payload) } -fn parse_catalog(provider: &ProviderId, payload: &str) -> Result, ProviderError> { +/// `source` is the base URL, for the error messages. +fn parse_catalog( + provider: &ProviderId, + source: &str, + payload: &str, +) -> Result, ProviderError> { #[derive(Deserialize)] struct Listing { data: Vec, } let listing: Listing = serde_json::from_str(payload).map_err(|error| { - ProviderError::Fetch(format!("not an OpenAI-style model list: {error}")) + ProviderError::Fetch(format!( + "{source}/models did not return a Chat Completions model list: {error}" + )) })?; let mut models: Vec = listing .data @@ -255,7 +275,7 @@ fn parse_catalog(provider: &ProviderId, payload: &str) -> Result, models.sort_by(|a, b| a.id.cmp(&b.id)); models.dedup_by(|a, b| a.id == b.id); if models.is_empty() { - return Err(ProviderError::Fetch("server listed no models".into())); + return Err(ProviderError::Fetch(format!("{source} lists no models"))); } Ok(models) } @@ -275,6 +295,8 @@ mod tests { const PROVIDER: &str = "grok"; + const SOURCE: &str = "http://x/v1"; + fn id() -> ProviderId { ProviderId::new(PROVIDER) } @@ -307,6 +329,7 @@ mod tests { fn parse_catalog_reads_each_known_context_field() { let models = parse_catalog( &id(), + SOURCE, r#"{"data":[ {"id":"a","context_length":128000}, {"id":"b","context_window":131072}, @@ -336,6 +359,7 @@ mod tests { fn context_length_takes_the_first_field_present() { let models = parse_catalog( &id(), + SOURCE, r#"{"data":[{"id":"a","context_length":1000,"context_window":2000}]}"#, ) .unwrap(); @@ -344,9 +368,7 @@ mod tests { #[test] fn context_length_ignores_zero_strings_and_floats() { - let models = parse_catalog( - &id(), - r#"{"data":[ + let models = parse_catalog(&id(), SOURCE, r#"{"data":[ {"id":"a","context_length":0,"context_window":"4096","max_context_length":4096.5,"max_model_len":2048}, {"id":"b","context_length":"128k"}]}"#, ) @@ -357,9 +379,7 @@ mod tests { #[test] fn parse_catalog_sorts_dedupes_and_skips_blank_ids() { - let models = parse_catalog( - &id(), - r#"{"data":[{"id":"b"},{"id":"a","owned_by":"x"},{"id":"b"},{"id":" "},{"object":"model"}]}"#, + let models = parse_catalog(&id(), SOURCE, r#"{"data":[{"id":"b"},{"id":"a","owned_by":"x"},{"id":"b"},{"id":" "},{"object":"model"}]}"#, ) .unwrap(); let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect(); @@ -368,13 +388,17 @@ mod tests { #[test] fn parse_catalog_rejects_empty_and_other_shapes() { - assert!(parse_catalog(&id(), r#"{"data":[]}"#).is_err()); - assert!(parse_catalog(&id(), r#"{"models":[{"name":"models/gemini"}]}"#).is_err()); - let error = parse_catalog(&id(), "404").unwrap_err(); + let empty = parse_catalog(&id(), SOURCE, r#"{"data":[]}"#).unwrap_err(); + assert!( + empty.to_string().contains("http://x/v1 lists no models"), + "{empty}" + ); + assert!(parse_catalog(&id(), SOURCE, r#"{"models":[{"name":"models/gemini"}]}"#).is_err()); + let error = parse_catalog(&id(), SOURCE, "404").unwrap_err(); assert!( error .to_string() - .contains("not an OpenAI-style model list: "), + .contains("http://x/v1/models did not return a Chat Completions model list: "), "{error}" ); } @@ -433,7 +457,12 @@ mod tests { ) .await .unwrap_err(); - assert!(error.to_string().contains("could not connect"), "{error}"); + assert!( + error.to_string().contains(&format!( + "could not reach http://{address}/v1; is the server running?" + )), + "{error}" + ); } #[tokio::test] @@ -441,7 +470,27 @@ mod tests { let error = fetch_catalog(&id(), &ApiId::ChatCompletions, "not a url", None) .await .unwrap_err(); - assert!(error.to_string().contains("invalid URL"), "{error}"); + assert!( + error.to_string().contains( + "\"not a url\" is not a valid URL; it should look like http://localhost:11434/v1" + ), + "{error}" + ); + } + + #[tokio::test] + async fn fetch_catalog_explains_a_wrong_path() { + let (url, _server) = + serve_once("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n").await; + let error = fetch_catalog(&id(), &ApiId::ChatCompletions, &url, None) + .await + .unwrap_err(); + assert!( + error.to_string().contains( + "/v1/models was not found; the URL should end where /chat/completions would start, e.g. …/v1" + ), + "{error}" + ); } #[tokio::test] @@ -501,7 +550,7 @@ mod tests { .validate_auth(&AuthResult::ApiKey("bad".into())) .await; assert!( - matches!(&result, Err(AuthError::Validation(reason)) if reason == "API key was rejected (HTTP 401 Unauthorized)"), + matches!(&result, Err(AuthError::Validation(reason)) if reason == "the API key was rejected (HTTP 401 Unauthorized)"), "{result:?}" ); } From 2a420d388ac87aa465a6de12ead23034a712bd75 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Thu, 15 Oct 2026 14:13:08 +0530 Subject: [PATCH 35/36] Show a text cursor in the provider form, like other inputs, instead of a highlighted row --- crates/alan/src/provider_overlay.rs | 75 +++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs index 988ef7a..f36bae2 100644 --- a/crates/alan/src/provider_overlay.rs +++ b/crates/alan/src/provider_overlay.rs @@ -305,7 +305,7 @@ fn render_provider_overlay( let url = fields.url.trim().trim_end_matches('/'); match state { - OverlayState::Editing => render_fields(frame, inner, fields, focused, accent, muted), + OverlayState::Editing => render_fields(frame, inner, fields, focused, mode, accent, muted), OverlayState::Checking => frame.render_widget( Paragraph::new(format!(" Checking {url}/models…")).style(accent), inner, @@ -333,9 +333,11 @@ fn render_fields( inner: Rect, fields: &FormFields, focused: usize, + mode: Mode, accent: Style, muted: Style, ) { + let value_style = Style::default().fg(theme::EDITOR_FG); let max_label = FIELD_LABELS .iter() .map(|label| label.width()) @@ -347,13 +349,6 @@ fn render_fields( break; } let is_focused = i == focused; - let value_style = if is_focused { - Style::default() - .fg(theme::SELECTION_FG) - .bg(theme::SELECTION_BG) - } else { - Style::default().fg(theme::EDITOR_FG) - }; let text = if i == NAME { &fields.name } else { &fields.url }; let value = match i { API => Span::styled("Chat Completions", value_style), @@ -376,15 +371,20 @@ fn render_fields( ]; let value_column: usize = spans.iter().map(Span::width).sum(); spans.push(value); - let line = Line::from(spans); frame.render_widget( - Paragraph::new(line).style(if is_focused { - Style::default().bg(theme::SELECTION_BG) - } else { - Style::default() - }), + Paragraph::new(Line::from(spans)), Rect::new(inner.x, y, inner.width, 1), ); + let typed = match i { + NAME if mode == Mode::Add => Some(fields.name.width()), + URL => Some(fields.url.width()), + API_KEY => Some(fields.api_key.chars().count()), + _ => None, + }; + if is_focused && let Some(typed) = typed { + let x = (inner.x as usize + value_column + typed).min(inner.right() as usize - 1); + frame.set_cursor_position((x as u16, y)); + } // No colon in the hint: the alignment test counts one per field row. if i == URL && fields.url.is_empty() { frame.render_widget( @@ -551,6 +551,53 @@ mod tests { assert!(colons.iter().all(|&c| c == colons[0]), "{colons:?}"); } + #[test] + fn the_focused_field_has_a_cursor_not_a_highlighted_row() { + let fields = FormFields { + name: "grok".into(), + ..FormFields::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 18)).unwrap(); + terminal + .draw(|frame| { + render_provider_overlay( + frame, + frame.area(), + &fields, + NAME, + &OverlayState::Editing, + Mode::Add, + ) + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content + .iter() + .all(|cell| cell.bg != theme::SELECTION_BG) + ); + let rows: Vec> = buffer + .content + .iter() + .flat_map(|cell| cell.symbol().chars()) + .collect::>() + .chunks(80) + .map(<[char]>::to_vec) + .collect(); + let (y, x) = rows + .iter() + .enumerate() + .find_map(|(y, row)| { + let x = row.windows(4).position(|w| w == ['g', 'r', 'o', 'k'])?; + Some((y as u16, x as u16 + 4)) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!((cursor.x, cursor.y), (x, y)); + } + #[test] fn an_error_shows_the_reason() { let screen = screen( From 31c6d301ba512ccbc3dddb0923b7692472e4c410 Mon Sep 17 00:00:00 2001 From: Azeem Shaik Date: Sun, 18 Oct 2026 15:21:32 +0530 Subject: [PATCH 36/36] Keep custom providers in one registry list and save them through one store --- AGENTS.md | 11 +- crates/alan/src/core/custom_providers.rs | 740 +++++++----------- crates/alan/src/main.rs | 17 +- crates/alan/src/provider_overlay.rs | 89 ++- .../src/views/components/chat_view/mod.rs | 11 +- .../src/views/components/chat_view/models.rs | 70 +- crates/providers/src/custom.rs | 52 +- crates/providers/src/provider.rs | 122 ++- 8 files changed, 461 insertions(+), 651 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac5ad09..914a1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,11 +66,12 @@ in with `/login`, or set the provider's API key variable: OPENROUTER_API_KEY=... cargo run -p alan ``` -`ProviderRegistry` keeps two tracks: the built-ins from `build_providers`, fixed -at startup, and the custom providers added with `/provider add`. Custom -providers live in `core::custom_providers`: name, URL and API standard -(`ApiId`) in `/providers.json`, key only in `auth.json`, registered -as `providers::CustomProvider`. Only `ApiId::ChatCompletions` is implemented. +`ProviderRegistry` holds the built-ins from `build_providers`, then the custom +providers added with `/provider add`; a built-in wins a name clash. +`core::custom_providers::CustomProviderStore` keeps a custom provider's name, +URL and API standard (`ApiId`) in `/providers.json` and its key in +`auth.json`. `/provider` saves there first, then registers a +`providers::CustomProvider`. Only `ApiId::ChatCompletions` is implemented. Optional model override: diff --git a/crates/alan/src/core/custom_providers.rs b/crates/alan/src/core/custom_providers.rs index 0bda1b1..9297547 100644 --- a/crates/alan/src/core/custom_providers.rs +++ b/crates/alan/src/core/custom_providers.rs @@ -5,8 +5,8 @@ use anyhow::Result; use providers::{ - ApiId, Credential, CredentialStore, CustomProvider, ModelInfo, ProviderError, ProviderId, - ProviderRegistry, fetch_catalog, + ApiId, Credential, CredentialStore, CustomProvider, ModelInfo, Provider, ProviderError, + ProviderId, ProviderRegistry, fetch_catalog, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -63,35 +63,94 @@ pub struct CustomProviderEntry { pub api: ApiId, } -/// `providers.json`: the saved custom providers, in the order they were added. +/// The saved custom providers: entries in `providers.json`, in the order +/// they were added, and their keys in `auth.json`. /// /// Entries are edited as raw JSON, matched by name, so one Alan cannot read /// (a hand edit, or an API standard from a newer Alan) is kept and does not /// block saving the others. pub struct CustomProviderStore { store: JsonStore, + credentials: Arc, } impl CustomProviderStore { - pub fn new(path: impl Into) -> Self { + pub fn new(path: impl Into, credentials: Arc) -> Self { Self { store: JsonStore::new(path), + credentials, } } /// The entries Alan can read. - pub async fn load(&self) -> Result> { + pub async fn entries(&self) -> Result> { Ok(self.load_each().await?.into_iter().flatten().collect()) } + /// The saved key, to prefill the edit form. + pub async fn key(&self, name: &ProviderName) -> Result> { + Ok(self + .credentials + .read(&name.id()) + .await? + .and_then(|credential| match credential { + Credential::ApiKey { key } => Some(key), + // `/provider` only saves API keys; sign-in tokens belong to built-ins. + Credential::OAuth { .. } => None, + })) + } + + /// Save a new provider and its key, or neither. + pub async fn add(&self, entry: &CustomProviderEntry, key: Option) -> Result<()> { + self.put(entry.clone()).await?; + if let Err(error) = self.save_key(&entry.name, key).await { + if let Err(undo) = self.remove_entry(&entry.name).await { + return Err(error.context(format!( + "{} is still in providers.json: {undo:#}", + entry.name + ))); + } + return Err(error); + } + Ok(()) + } + + /// Save a changed provider. No key removes the saved one. + pub async fn update(&self, entry: &CustomProviderEntry, key: Option) -> Result<()> { + self.put(entry.clone()).await?; + self.save_key(&entry.name, key).await + } + + pub async fn remove(&self, name: &ProviderName) -> Result<()> { + self.remove_entry(name).await?; + self.credentials.delete(&name.id()).await?; + Ok(()) + } + + /// The provider to register for `entry`; it reads its key from this + /// store's `auth.json` on every request. + pub fn provider( + &self, + entry: &CustomProviderEntry, + models: Vec, + ) -> Result> { + Ok(Arc::new(CustomProvider::new( + entry.name.id(), + entry.api.clone(), + entry.url.clone(), + Arc::clone(&self.credentials), + models, + )?)) + } + /// Each saved entry on its own, with the reason for any it cannot read. - pub async fn load_each(&self) -> Result>> { + async fn load_each(&self) -> Result>> { let values = raw_entries(self.store.get(PROVIDERS_KEY).await?)?; Ok(values.into_iter().map(serde_json::from_value).collect()) } /// Save `entry`, replacing a saved provider with the same name. - pub async fn put(&self, entry: CustomProviderEntry) -> Result<()> { + async fn put(&self, entry: CustomProviderEntry) -> Result<()> { let name = entry.name.to_string(); let value = serde_json::to_value(entry)?; self.store @@ -106,7 +165,7 @@ impl CustomProviderStore { .await } - pub async fn remove(&self, name: &ProviderName) -> Result<()> { + async fn remove_entry(&self, name: &ProviderName) -> Result<()> { let name = name.to_string(); self.store .update(PROVIDERS_KEY, move |saved| { @@ -116,6 +175,18 @@ impl CustomProviderStore { }) .await } + + async fn save_key(&self, name: &ProviderName, key: Option) -> Result<()> { + match key { + Some(key) => { + self.credentials + .put(&name.id(), Credential::ApiKey { key }) + .await? + } + None => self.credentials.delete(&name.id()).await?, + } + Ok(()) + } } fn raw_entries(value: Option) -> Result> { @@ -134,179 +205,75 @@ pub fn default_providers_path() -> Result { paths::data_file("providers.json") } -/// Adds, edits and removes custom providers, keeping `providers.json`, -/// `auth.json` and the live registry in step. -pub struct CustomProviders { - store: CustomProviderStore, - registry: Arc, - credentials: Arc, -} - -impl CustomProviders { - pub fn new( - store: CustomProviderStore, - registry: Arc, - credentials: Arc, - ) -> Self { - Self { - store, - registry, - credentials, +/// Register every saved provider Alan can use; their models are fetched the +/// next time `/models` opens. Entries it cannot read or bind are skipped. +/// Call it once the built-ins are registered: returns a notice for each entry +/// removed because a built-in now takes its name. +pub async fn load(store: &CustomProviderStore, registry: &ProviderRegistry) -> Result> { + let mut notices = Vec::new(); + for entry in store.load_each().await? { + let Ok(entry) = entry.inspect_err(|error| { + tracing::warn!(%error, "skipping an unreadable entry in providers.json"); + }) else { + continue; + }; + // The name, and its key slot in `auth.json`, now belong to the + // built-in, so only the entry goes. + if registry.get(&entry.name.id()).is_some() { + store.remove_entry(&entry.name).await?; + notices.push(format!( + "custom provider {} ({}) was removed: a built-in provider now uses that name; add it again under another name", + entry.name, entry.url + )); + continue; } - } - - /// Register every saved provider Alan can use; their models are fetched - /// the next time `/models` opens. Entries it cannot read or bind are - /// skipped. Returns a notice for each entry removed because a built-in now - /// takes its name. - pub async fn load(&self) -> Result> { - let mut notices = Vec::new(); - for entry in self.store.load_each().await? { - let Ok(entry) = entry.inspect_err(|error| { - tracing::warn!(%error, "skipping an unreadable entry in providers.json"); - }) else { - continue; - }; - // The name, and its key slot in `auth.json`, now belong to the - // built-in, so only the entry goes. - if self.registry.is_builtin(&entry.name.id()) { - self.store.remove(&entry.name).await?; - notices.push(format!( - "custom provider {} ({}) was removed: a built-in provider now uses that name; add it again under another name", - entry.name, entry.url - )); - continue; - } - if let Err(error) = self.register(&entry, Vec::new()) { - tracing::warn!(%error, provider = %entry.name, "skipping a custom provider"); + match store.provider(&entry, Vec::new()) { + Ok(provider) => registry.insert(provider), + Err(error) => { + tracing::warn!(%error, provider = %entry.name, "skipping a custom provider") } } - Ok(notices) - } - - pub async fn entries(&self) -> Result> { - self.store.load().await - } - - /// The saved key, to prefill the edit form. - pub async fn key(&self, name: &ProviderName) -> Result> { - Ok(self - .credentials - .read(&name.id()) - .await? - .and_then(|credential| match credential { - Credential::ApiKey { key } => Some(key), - // `/provider` only saves API keys; sign-in tokens belong to built-ins. - Credential::OAuth { .. } => None, - })) - } - - /// Check `url` with a `/models` call, then save the provider and its key. - /// Returns how many models the server lists. Nothing is saved when the - /// check fails. - pub async fn add( - &self, - name: ProviderName, - api: ApiId, - url: &str, - key: Option, - ) -> Result { - // A built-in's key in `auth.json` sits under the same name. - anyhow::ensure!( - !self.registry.is_builtin(&name.id()), - "{name} is a built-in provider; pick another name, e.g. {name}-2" - ); - anyhow::ensure!( - self.registry.custom(&name.id()).is_none(), - "{name} already exists; change it with /provider edit" - ); - let (entry, models) = self.check(name, api, url, key.as_deref()).await?; - self.store.put(entry.clone()).await?; - if let Err(error) = self.save_key(&entry.name, key).await { - if let Err(undo) = self.store.remove(&entry.name).await { - return Err(error.context(format!( - "{} is still in providers.json: {undo:#}", - entry.name - ))); - } - return Err(error); - } - let count = models.len(); - self.register(&entry, models)?; - Ok(count) - } - - /// [`add`](Self::add) for a saved provider. No key removes the saved one. - pub async fn update( - &self, - name: ProviderName, - api: ApiId, - url: &str, - key: Option, - ) -> Result { - let (entry, models) = self.check(name, api, url, key.as_deref()).await?; - self.store.put(entry.clone()).await?; - self.save_key(&entry.name, key).await?; - let count = models.len(); - self.register(&entry, models)?; - Ok(count) - } - - pub async fn remove(&self, name: &ProviderName) -> Result<()> { - self.store.remove(name).await?; - self.credentials.delete(&name.id()).await?; - self.registry.remove_custom(&name.id()); - Ok(()) - } - - async fn check( - &self, - name: ProviderName, - api: ApiId, - url: &str, - key: Option<&str>, - ) -> Result<(CustomProviderEntry, Vec)> { - let url = url.trim().trim_end_matches('/').to_owned(); - // The reason already names the URL; the error's own prefix would - // only repeat that the check failed. - let models = - fetch_catalog(&name.id(), &api, &url, key) - .await - .map_err(|error| match error { - ProviderError::Fetch(reason) => anyhow::anyhow!(reason), - other => other.into(), - })?; - Ok((CustomProviderEntry { name, url, api }, models)) } + Ok(notices) +} - async fn save_key(&self, name: &ProviderName, key: Option) -> Result<()> { - match key { - Some(key) => { - self.credentials - .put(&name.id(), Credential::ApiKey { key }) - .await? - } - None => self.credentials.delete(&name.id()).await?, - } - Ok(()) - } +/// Refuse a name `/provider add` cannot take, before any request. +pub async fn ensure_new_name( + store: &CustomProviderStore, + registry: &ProviderRegistry, + name: &ProviderName, +) -> Result<()> { + let saved = store.entries().await?; + anyhow::ensure!( + saved.iter().all(|entry| entry.name.id() != name.id()), + "{name} already exists; change it with /provider edit" + ); + // A built-in's key in `auth.json` sits under the same name. + anyhow::ensure!( + registry.get(&name.id()).is_none(), + "{name} is a built-in provider; pick another name, e.g. {name}-2" + ); + Ok(()) +} - fn register(&self, entry: &CustomProviderEntry, models: Vec) -> Result<()> { - // A bound model keeps its provider's API client: replacing the - // provider on an edit would leave the running model on the old URL. - if let Some(provider) = self.registry.custom(&entry.name.id()) { - provider.set_base_url(entry.url.clone()); - return Ok(()); - } - self.registry.add_custom(Arc::new(CustomProvider::new( - entry.name.id(), - entry.api.clone(), - entry.url.clone(), - Arc::clone(&self.credentials), - models, - )?)); - Ok(()) - } +/// Check `url` with a `/models` call. Returns the entry to save and the +/// models the server lists. +pub async fn check( + name: ProviderName, + api: ApiId, + url: &str, + key: Option<&str>, +) -> Result<(CustomProviderEntry, Vec)> { + let url = url.trim().trim_end_matches('/').to_owned(); + // The reason already names the URL; the error's own prefix would only + // repeat that the check failed. + let models = fetch_catalog(&name.id(), &api, &url, key) + .await + .map_err(|error| match error { + ProviderError::Fetch(reason) => anyhow::anyhow!(reason), + other => other.into(), + })?; + Ok((CustomProviderEntry { name, url, api }, models)) } #[cfg(test)] @@ -338,7 +305,15 @@ pub(crate) mod tests { ProviderName::parse(value).unwrap() } - pub(crate) fn provider(id: &str, models: &[&str]) -> Arc { + fn entry(value: &str, url: &str) -> CustomProviderEntry { + CustomProviderEntry { + name: name(value), + url: url.into(), + api: ApiId::ChatCompletions, + } + } + + pub(crate) fn provider(id: &str, models: &[&str]) -> Arc { let models = models .iter() .map(|model| ModelInfo::new(model, ProviderId::new(id))) @@ -355,48 +330,40 @@ pub(crate) mod tests { ) } - fn service( - file: &str, - ) -> ( - CustomProviders, - Arc, - Arc, - ) { - let registry = Arc::new(ProviderRegistry::new([])); + fn store(file: &str) -> (CustomProviderStore, Arc) { let credentials = Arc::new(InMemoryCredentialStore::new()); - let providers = CustomProviders::new( - CustomProviderStore::new(temp_path(file)), - Arc::clone(®istry), - credentials.clone(), - ); - (providers, registry, credentials) + let store = CustomProviderStore::new(temp_path(file), credentials.clone()); + (store, credentials) } - // Replies `response` to `count` connections, hands back each request. - async fn serve( - response: &'static str, - count: usize, - ) -> (String, tokio::task::JoinHandle>) { + async fn names(store: &CustomProviderStore) -> Vec { + store + .entries() + .await + .unwrap() + .into_iter() + .map(|e| e.name.to_string()) + .collect() + } + + // Replies `response` to one connection, hands back the request. + async fn serve(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 requests = Vec::new(); - for _ in 0..count { - 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(); - requests.push(String::from_utf8(request).unwrap()); + 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]); } - requests + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() }); (format!("http://{address}/v1/"), server) } @@ -414,52 +381,74 @@ pub(crate) mod tests { } #[tokio::test] - async fn add_refuses_a_builtin_name_before_any_request() { - let registry = Arc::new(ProviderRegistry::new([ - provider("deepseek", &[]) as Arc - ])); - let providers = CustomProviders::new( - CustomProviderStore::new(temp_path("builtin")), - registry, - Arc::new(InMemoryCredentialStore::new()), - ); + async fn ensure_new_name_refuses_saved_and_builtin_names() { + let (store, _credentials) = store("names"); + store.add(&entry("grok", "http://x"), None).await.unwrap(); + let registry = ProviderRegistry::new([provider("deepseek", &[])]); - // Port 1 is closed: reaching the network would say "could not connect". - let error = providers - .add( - name("deepseek"), - ApiId::ChatCompletions, - "http://127.0.0.1:1/v1", - None, - ) + let saved = ensure_new_name(&store, ®istry, &name("grok")) + .await + .unwrap_err(); + let builtin = ensure_new_name(&store, ®istry, &name("deepseek")) .await .unwrap_err(); assert_eq!( - error.to_string(), + saved.to_string(), + "grok already exists; change it with /provider edit" + ); + assert_eq!( + builtin.to_string(), "deepseek is a built-in provider; pick another name, e.g. deepseek-2" ); + assert!( + ensure_new_name(&store, ®istry, &name("groq")) + .await + .is_ok() + ); + cleanup("names"); + } + + #[tokio::test] + async fn check_trims_the_url_and_lists_the_models() { + let url = serve(OK_TWO_MODELS).await.0; + + let (entry, models) = check(name("grok"), ApiId::ChatCompletions, &url, None) + .await + .unwrap(); + + assert_eq!(entry.url, url.trim_end_matches('/')); + assert_eq!(models.len(), 2); + } + + #[tokio::test] + async fn check_reports_only_the_reason() { + let url = serve("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}") + .await + .0; + + let error = check(name("grok"), ApiId::ChatCompletions, &url, Some("bad")) + .await + .unwrap_err(); + + assert_eq!( + format!("{error:#}"), + "the API key was rejected (HTTP 401 Unauthorized)" + ); } #[tokio::test] - async fn store_put_replaces_by_name_and_keeps_order() { - let store = CustomProviderStore::new(temp_path("put")); + async fn put_replaces_by_name_and_keeps_order() { + let (store, _credentials) = store("put"); for (n, url) in [ ("a", "http://one"), ("b", "http://two"), ("a", "http://three"), ] { - store - .put(CustomProviderEntry { - name: name(n), - url: url.into(), - api: ApiId::ChatCompletions, - }) - .await - .unwrap(); + store.put(entry(n, url)).await.unwrap(); } let urls: Vec<_> = store - .load() + .entries() .await .unwrap() .into_iter() @@ -479,34 +468,14 @@ pub(crate) mod tests { ) .await .unwrap(); - let store = CustomProviderStore::new(path.clone()); + let store = + CustomProviderStore::new(path.clone(), Arc::new(InMemoryCredentialStore::new())); - store - .put(CustomProviderEntry { - name: name("grok"), - url: "http://one".into(), - api: ApiId::ChatCompletions, - }) - .await - .unwrap(); + store.add(&entry("grok", "http://one"), None).await.unwrap(); store.remove(&name("grok")).await.unwrap(); - store - .put(CustomProviderEntry { - name: name("groq"), - url: "http://two".into(), - api: ApiId::ChatCompletions, - }) - .await - .unwrap(); + store.add(&entry("groq", "http://two"), None).await.unwrap(); - let names: Vec<_> = store - .load() - .await - .unwrap() - .into_iter() - .map(|e| e.name.to_string()) - .collect(); - assert_eq!(names, ["groq"]); + assert_eq!(names(&store).await, ["groq"]); let raw = JsonStore::new(path) .get(PROVIDERS_KEY) .await @@ -517,89 +486,23 @@ pub(crate) mod tests { } #[tokio::test] - async fn add_checks_then_saves_entry_key_and_provider() { - let (providers, registry, _credentials) = service("add"); - let url = serve(OK_TWO_MODELS, 1).await.0; + async fn add_saves_the_entry_and_key_and_remove_deletes_both() { + let (store, _credentials) = store("add-remove"); - let count = providers - .add( - name("grok"), - ApiId::ChatCompletions, - &url, - Some("sk".into()), - ) + store + .add(&entry("grok", "http://x/v1"), Some("sk".into())) .await .unwrap(); - - assert_eq!(count, 2); - let entries = providers.entries().await.unwrap(); - assert_eq!(entries[0].url, url.trim_end_matches('/')); - assert_eq!(entries[0].api, ApiId::ChatCompletions); + assert_eq!(names(&store).await, ["grok"]); assert_eq!( - providers.key(&name("grok")).await.unwrap().as_deref(), + store.key(&name("grok")).await.unwrap().as_deref(), Some("sk") ); - let provider = registry.get(&name("grok").id()).unwrap(); - assert_eq!(provider.models().len(), 2); - cleanup("add"); - } - - #[tokio::test] - async fn add_saves_nothing_when_the_check_fails() { - let (providers, registry, credentials) = service("add-fails"); - let url = serve( - "HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{}", - 1, - ) - .await - .0; - - let error = providers - .add( - name("grok"), - ApiId::ChatCompletions, - &url, - Some("bad".into()), - ) - .await - .unwrap_err(); - - assert_eq!( - format!("{error:#}"), - "the API key was rejected (HTTP 401 Unauthorized)" - ); - assert!(providers.entries().await.unwrap().is_empty()); - assert!( - credentials - .read(&name("grok").id()) - .await - .unwrap() - .is_none() - ); - assert!(registry.get(&name("grok").id()).is_none()); - cleanup("add-fails"); - } - #[tokio::test] - async fn add_refuses_a_taken_name_before_any_request() { - let (providers, registry, _credentials) = service("taken"); - registry.add_custom(provider("taken", &[])); - - // Port 1 is closed: reaching the network would say "could not connect". - let error = providers - .add( - name("taken"), - ApiId::ChatCompletions, - "http://127.0.0.1:1/v1", - None, - ) - .await - .unwrap_err(); - - assert_eq!( - error.to_string(), - "taken already exists; change it with /provider edit" - ); + store.remove(&name("grok")).await.unwrap(); + assert!(names(&store).await.is_empty()); + assert!(store.key(&name("grok")).await.unwrap().is_none()); + cleanup("add-remove"); } #[tokio::test] @@ -608,20 +511,10 @@ pub(crate) mod tests { let path = temp_path("unwritable"); std::fs::create_dir_all(&path).unwrap(); let credentials = Arc::new(InMemoryCredentialStore::new()); - let providers = CustomProviders::new( - CustomProviderStore::new(path.clone()), - Arc::new(ProviderRegistry::new([])), - credentials.clone(), - ); - let url = serve(OK_TWO_MODELS, 1).await.0; + let store = CustomProviderStore::new(path.clone(), credentials.clone()); - let result = providers - .add( - name("grok"), - ApiId::ChatCompletions, - &url, - Some("sk".into()), - ) + let result = store + .add(&entry("grok", "http://x"), Some("sk".into())) .await; assert!(result.is_err()); @@ -638,27 +531,14 @@ pub(crate) mod tests { #[tokio::test] async fn add_takes_the_entry_back_when_the_key_cannot_be_saved() { - let (providers_json, credentials) = (temp_path("key-fails"), Arc::new(FailingKeys)); - let registry = Arc::new(ProviderRegistry::new([])); - let providers = CustomProviders::new( - CustomProviderStore::new(providers_json), - Arc::clone(®istry), - credentials, - ); - let url = serve(OK_TWO_MODELS, 1).await.0; + let store = CustomProviderStore::new(temp_path("key-fails"), Arc::new(FailingKeys)); - let result = providers - .add( - name("grok"), - ApiId::ChatCompletions, - &url, - Some("sk".into()), - ) + let result = store + .add(&entry("grok", "http://x"), Some("sk".into())) .await; assert!(result.is_err()); - assert!(providers.entries().await.unwrap().is_empty()); - assert!(registry.custom(&name("grok").id()).is_none()); + assert!(names(&store).await.is_empty()); cleanup("key-fails"); } @@ -686,82 +566,22 @@ pub(crate) mod tests { #[tokio::test] async fn update_without_a_key_deletes_the_saved_one() { - let (providers, _registry, credentials) = service("update"); - credentials - .put(&name("grok").id(), Credential::ApiKey { key: "old".into() }) + let (store, _credentials) = store("update"); + store + .add(&entry("grok", "http://one"), Some("old".into())) .await .unwrap(); - let url = serve(OK_TWO_MODELS, 1).await.0; - providers - .update(name("grok"), ApiId::ChatCompletions, &url, None) + store + .update(&entry("grok", "http://two"), None) .await .unwrap(); - assert!(providers.key(&name("grok")).await.unwrap().is_none()); + assert!(store.key(&name("grok")).await.unwrap().is_none()); + assert_eq!(store.entries().await.unwrap()[0].url, "http://two"); cleanup("update"); } - #[tokio::test] - async fn update_moves_models_already_bound_to_the_new_url() { - let (providers, registry, _credentials) = service("edit-bound"); - let old_url = serve(OK_TWO_MODELS, 1).await.0; - providers - .add(name("grok"), ApiId::ChatCompletions, &old_url, None) - .await - .unwrap(); - let provider = registry.get(&name("grok").id()).unwrap(); - let model = providers::bind_model(provider.as_ref(), "m1", Default::default()).unwrap(); - - // The new server answers the edit's check, then the chat request. - let (new_url, server) = serve(OK_TWO_MODELS, 2).await; - providers - .update(name("grok"), ApiId::ChatCompletions, &new_url, None) - .await - .unwrap(); - let messages = [llm::Message::user("hi")]; - let _ = model - .stream(llm::CompletionInput { - messages: &messages, - tools: &[], - options: &Default::default(), - extensions: &Default::default(), - }) - .await; - - let requests = tokio::time::timeout(std::time::Duration::from_secs(5), server) - .await - .expect("the chat request never reached the new URL") - .unwrap(); - assert!( - requests[1].starts_with("POST /v1/chat/completions"), - "{requests:?}" - ); - cleanup("edit-bound"); - } - - #[tokio::test] - async fn remove_deletes_entry_key_and_provider() { - let (providers, registry, _credentials) = service("remove-all"); - let url = serve(OK_TWO_MODELS, 1).await.0; - providers - .add( - name("grok"), - ApiId::ChatCompletions, - &url, - Some("sk".into()), - ) - .await - .unwrap(); - - providers.remove(&name("grok")).await.unwrap(); - - assert!(providers.entries().await.unwrap().is_empty()); - assert!(providers.key(&name("grok")).await.unwrap().is_none()); - assert!(registry.get(&name("grok").id()).is_none()); - cleanup("remove-all"); - } - #[tokio::test] async fn load_skips_entries_it_cannot_use_and_keeps_the_rest() { let path = temp_path("load-skips"); @@ -777,14 +597,10 @@ pub(crate) mod tests { ) .await .unwrap(); - let registry = Arc::new(ProviderRegistry::new([])); - let providers = CustomProviders::new( - CustomProviderStore::new(path), - Arc::clone(®istry), - Arc::new(InMemoryCredentialStore::new()), - ); + let registry = ProviderRegistry::new([]); + let store = CustomProviderStore::new(path, Arc::new(InMemoryCredentialStore::new())); - let notices = providers.load().await.unwrap(); + let notices = load(&store, ®istry).await.unwrap(); assert!(notices.is_empty(), "{notices:?}"); let ids: Vec<_> = registry.providers().iter().map(|p| p.id().0).collect(); @@ -802,19 +618,10 @@ pub(crate) mod tests { #[tokio::test] async fn load_drops_an_entry_a_builtin_now_names_and_keeps_the_builtins_key() { - let path = temp_path("load-clash"); - let store = CustomProviderStore::new(path.clone()); + let (store, credentials) = store("load-clash"); for n in ["openai", "grok"] { - store - .put(CustomProviderEntry { - name: name(n), - url: "http://localhost:1/v1".into(), - api: ApiId::ChatCompletions, - }) - .await - .unwrap(); + store.put(entry(n, "http://localhost:1/v1")).await.unwrap(); } - let credentials = Arc::new(InMemoryCredentialStore::new()); credentials .put( &name("openai").id(), @@ -822,16 +629,9 @@ pub(crate) mod tests { ) .await .unwrap(); - let registry = Arc::new(ProviderRegistry::new([ - provider("openai", &[]) as Arc - ])); - let providers = CustomProviders::new( - CustomProviderStore::new(path), - Arc::clone(®istry), - credentials.clone(), - ); + let registry = ProviderRegistry::new([provider("openai", &["gpt"])]); - let notices = providers.load().await.unwrap(); + let notices = load(&store, ®istry).await.unwrap(); assert_eq!( notices, @@ -839,17 +639,11 @@ pub(crate) mod tests { "custom provider openai (http://localhost:1/v1) was removed: a built-in provider now uses that name; add it again under another name" ] ); - assert!(registry.custom(&name("openai").id()).is_none()); - assert!(registry.custom(&name("grok").id()).is_some()); - let names: Vec<_> = providers - .entries() - .await - .unwrap() - .into_iter() - .map(|e| e.name.to_string()) - .collect(); - assert_eq!(names, ["grok"]); - // The key under that name now belongs to the built-in. + assert_eq!(names(&store).await, ["grok"]); + // The built-in keeps its place and its key. + let openai = registry.get(&name("openai").id()).unwrap(); + assert_eq!(openai.models()[0].id, "gpt"); + assert!(registry.get(&name("grok").id()).is_some()); assert!( credentials .read(&name("openai").id()) diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index eee998f..471f271 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -6,7 +6,7 @@ mod provider_overlay; mod root; mod views; -use crate::core::custom_providers::{self, CustomProviderStore, CustomProviders}; +use crate::core::custom_providers::{self, CustomProviderStore}; use crate::core::permissions::AlanPermissionManager; use crate::core::permissions::ToolPolicy; use crate::core::permissions_store; @@ -73,15 +73,16 @@ async fn main() -> anyhow::Result<()> { let providers = build_providers(&credential_store_for_providers)?; let registry = Arc::new(ProviderRegistry::new(providers)); - let custom_providers = CustomProviders::new( - CustomProviderStore::new(custom_providers::default_providers_path()?), - Arc::clone(®istry), + let custom_provider_store = CustomProviderStore::new( + custom_providers::default_providers_path()?, Arc::clone(&credential_store_for_providers), ); - let startup_notices = custom_providers.load().await.unwrap_or_else(|error| { - tracing::warn!(%error, "failed to load custom providers"); - Vec::new() - }); + let startup_notices = custom_providers::load(&custom_provider_store, ®istry) + .await + .unwrap_or_else(|error| { + tracing::warn!(%error, "failed to load custom providers"); + Vec::new() + }); let session_manager = Arc::new(SessionManager::new(sessions_path()?)); let resumed_session = load_resumed_session(&session_manager).await?; let model = select_model(®istry, &settings, resumed_session.as_ref())?; diff --git a/crates/alan/src/provider_overlay.rs b/crates/alan/src/provider_overlay.rs index f36bae2..0000b30 100644 --- a/crates/alan/src/provider_overlay.rs +++ b/crates/alan/src/provider_overlay.rs @@ -1,12 +1,12 @@ //! `/provider add` and `/provider edit`: the form for a custom provider. -use crate::core::custom_providers::{CustomProviderEntry, CustomProviders, ProviderName}; +use crate::core::custom_providers::{self, CustomProviderEntry, CustomProviderStore, ProviderName}; use crate::root::AlanAction; use crate::views::theme; use alan_tui::context::Context; use alan_tui::{ActionStatus, Component, RenderContext, TaskError}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use providers::ApiId; +use providers::{ApiId, ProviderId, ProviderRegistry}; use ratatui::Frame; use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Modifier, Style}; @@ -65,6 +65,9 @@ enum OverlayState { Error(String), } +/// Emitted once a provider is saved and registered under this id. +pub struct ProviderSaved(pub ProviderId); + /// What a key press needs from the host beyond the overlay's own state. #[derive(Debug, PartialEq, Eq)] enum KeyOutcome { @@ -75,7 +78,8 @@ enum KeyOutcome { } pub struct ProviderOverlay { - providers: Arc, + store: Arc, + registry: Arc, mode: Mode, api: ApiId, fields: FormFields, @@ -84,9 +88,10 @@ pub struct ProviderOverlay { } impl ProviderOverlay { - pub fn add(providers: Arc) -> Self { + pub fn add(store: Arc, registry: Arc) -> Self { Self { - providers, + store, + registry, mode: Mode::Add, api: ApiId::ChatCompletions, fields: FormFields::default(), @@ -98,7 +103,8 @@ impl ProviderOverlay { /// The name is fixed: `auth.json`, `settings.json` and profiles refer to /// the provider by it. pub fn edit( - providers: Arc, + store: Arc, + registry: Arc, entry: CustomProviderEntry, key: Option, ) -> Self { @@ -111,7 +117,7 @@ impl ProviderOverlay { api_key: key.unwrap_or_default(), }, focused: URL, - ..Self::add(providers) + ..Self::add(store, registry) } } @@ -180,21 +186,22 @@ impl ProviderOverlay { return; } }; - let providers = Arc::clone(&self.providers); + let store = Arc::clone(&self.store); + let registry = Arc::clone(&self.registry); let mode = self.mode; let api = self.api.clone(); cx.spawn( async move { - let Submission { name, url, key } = submission; - match mode { - Mode::Add => providers.add(name, api, &url, key).await, - Mode::Edit => providers.update(name, api, &url, key).await, - } - .map_err(|error| TaskError(format!("{error:#}").into())) + save(&store, ®istry, mode, api, submission) + .await + .map_err(|error| TaskError(format!("{error:#}").into())) }, |result, overlay, cx| { overlay.state = match result { - Ok(count) => OverlayState::Saved(count), + Ok((id, count)) => { + cx.emit(ProviderSaved(id)); + OverlayState::Saved(count) + } Err(error) => OverlayState::Error(error.to_string()), }; cx.notify(); @@ -205,6 +212,29 @@ impl ProviderOverlay { } } +/// Check the server, save the provider, then register it. Nothing is saved +/// when the check fails. Returns how many models the server lists. +async fn save( + store: &CustomProviderStore, + registry: &ProviderRegistry, + mode: Mode, + api: ApiId, + submission: Submission, +) -> anyhow::Result<(ProviderId, usize)> { + let Submission { name, url, key } = submission; + if mode == Mode::Add { + custom_providers::ensure_new_name(store, registry, &name).await?; + } + let (entry, models) = custom_providers::check(name, api, &url, key.as_deref()).await?; + match mode { + Mode::Add => store.add(&entry, key).await?, + Mode::Edit => store.update(&entry, key).await?, + } + let count = models.len(); + registry.insert(store.provider(&entry, models)?); + Ok((entry.name.id(), count)) +} + impl Component for ProviderOverlay { fn handle_action( &mut self, @@ -404,21 +434,23 @@ fn render_fields( #[cfg(test)] mod tests { use super::*; - use crate::core::custom_providers::CustomProviderStore; - use providers::{InMemoryCredentialStore, ProviderRegistry}; + use providers::InMemoryCredentialStore; use ratatui::{Terminal, backend::TestBackend}; - fn providers() -> Arc { - Arc::new(CustomProviders::new( - CustomProviderStore::new(std::env::temp_dir().join(format!( + fn store() -> Arc { + Arc::new(CustomProviderStore::new( + std::env::temp_dir().join(format!( "alan-provider-overlay-test-{}.json", std::process::id() - ))), - Arc::new(ProviderRegistry::new([])), + )), Arc::new(InMemoryCredentialStore::new()), )) } + fn overlay() -> ProviderOverlay { + ProviderOverlay::add(store(), Arc::new(ProviderRegistry::new([]))) + } + fn press(overlay: &mut ProviderOverlay, code: KeyCode) -> KeyOutcome { overlay.on_key(KeyEvent::new(code, KeyModifiers::NONE)) } @@ -447,7 +479,7 @@ mod tests { #[test] fn typing_fills_the_focused_field_and_tab_moves_on() { - let mut overlay = ProviderOverlay::add(providers()); + let mut overlay = overlay(); type_text(&mut overlay, "grok"); press(&mut overlay, KeyCode::Tab); type_text(&mut overlay, "https://api.x.ai/v1"); @@ -468,7 +500,12 @@ mod tests { url: "https://api.x.ai/v1".into(), api: ApiId::ChatCompletions, }; - let mut overlay = ProviderOverlay::edit(providers(), entry, Some("xai-1".into())); + let mut overlay = ProviderOverlay::edit( + store(), + Arc::new(ProviderRegistry::new([])), + entry, + Some("xai-1".into()), + ); assert_eq!(overlay.focused, URL); press(&mut overlay, KeyCode::BackTab); assert_eq!(press(&mut overlay, KeyCode::Char('x')), KeyOutcome::Ignore); @@ -478,7 +515,7 @@ mod tests { #[test] fn an_error_goes_back_to_the_form_with_fields_kept() { - let mut overlay = ProviderOverlay::add(providers()); + let mut overlay = overlay(); type_text(&mut overlay, "grok"); overlay.state = OverlayState::Error("the API key was rejected (HTTP 401 Unauthorized)".into()); @@ -490,7 +527,7 @@ mod tests { #[test] fn saved_closes_and_esc_cancels() { - let mut overlay = ProviderOverlay::add(providers()); + let mut overlay = overlay(); assert_eq!(press(&mut overlay, KeyCode::Esc), KeyOutcome::Close); overlay.state = OverlayState::Saved(3); assert_eq!(press(&mut overlay, KeyCode::Enter), KeyOutcome::Close); diff --git a/crates/alan/src/views/components/chat_view/mod.rs b/crates/alan/src/views/components/chat_view/mod.rs index 1189318..e0a6c5e 100644 --- a/crates/alan/src/views/components/chat_view/mod.rs +++ b/crates/alan/src/views/components/chat_view/mod.rs @@ -13,7 +13,7 @@ mod session; mod steering; use crate::core::chat::ChatController; -use crate::core::custom_providers::{self, CustomProviderStore, CustomProviders}; +use crate::core::custom_providers::{self, CustomProviderStore}; use crate::core::permissions::PermissionHandler; use crate::core::permissions::PermissionRequest; use crate::core::permissions::{Answer, Policy, ToolPolicy}; @@ -111,13 +111,12 @@ impl ChatView { } /// What `/provider` saves through. It holds only handles, so a fresh one - /// sees the same providers as the one `main.rs` loaded them with. - fn custom_providers(&self) -> Arc { + /// sees the same files as the one `main.rs` loaded them from. + fn custom_provider_store(&self) -> Arc { let path = custom_providers::default_providers_path() .expect("main.rs resolved the data directory at startup"); - Arc::new(CustomProviders::new( - CustomProviderStore::new(path), - Arc::clone(&self.providers), + Arc::new(CustomProviderStore::new( + path, Arc::clone(&self.credentials), )) } diff --git a/crates/alan/src/views/components/chat_view/models.rs b/crates/alan/src/views/components/chat_view/models.rs index 4754972..a02aaed 100644 --- a/crates/alan/src/views/components/chat_view/models.rs +++ b/crates/alan/src/views/components/chat_view/models.rs @@ -17,7 +17,7 @@ use crate::views::components::{SearchListEvent, SearchListOverlay}; use super::{ChatView, parse_provider_order, persist_provider_order}; use crate::core::custom_providers::{CustomProviderEntry, ProviderName}; -use crate::provider_overlay::ProviderOverlay; +use crate::provider_overlay::{ProviderOverlay, ProviderSaved}; /// `/models`: open the model picker overlay. pub(crate) fn open_models_picker(view: &mut ChatView, cx: &mut Context<'_, ChatView, AlanAction>) { @@ -185,7 +185,9 @@ pub(crate) fn handle_provider_command( let args = SlashCommand::parse_with_args(text).map(|(_, a)| a.trim().to_owned()); match args.as_deref() { Some("add") => { - cx.open_overlay(ProviderOverlay::add(view.custom_providers())); + let form = + ProviderOverlay::add(view.custom_provider_store(), Arc::clone(&view.providers)); + open_provider_form(cx, form); } Some("edit") => open_provider_picker(view, cx, ProviderPick::Edit), Some("remove") => open_provider_picker(view, cx, ProviderPick::Remove), @@ -205,9 +207,9 @@ fn open_provider_picker( ProviderPick::Edit => "Edit Provider", ProviderPick::Remove => "Remove Provider", }; - let providers = view.custom_providers(); + let store = view.custom_provider_store(); cx.spawn( - async move { providers.entries().await.map_err(super::to_task) }, + async move { store.entries().await.map_err(super::to_task) }, move |result, _view, cx| match result { Ok(entries) => { let labels = entries @@ -256,15 +258,16 @@ fn edit_provider( cx: &mut Context<'_, ChatView, AlanAction>, entry: CustomProviderEntry, ) { - let providers = view.custom_providers(); + let store = view.custom_provider_store(); cx.spawn( async move { - let key = providers.key(&entry.name).await.map_err(super::to_task)?; - Ok((entry, key)) + let key = store.key(&entry.name).await.map_err(super::to_task)?; + Ok((store, entry, key)) }, move |result, view, cx| match result { - Ok((entry, key)) => { - cx.open_overlay(ProviderOverlay::edit(view.custom_providers(), entry, key)); + Ok((store, entry, key)) => { + let form = ProviderOverlay::edit(store, Arc::clone(&view.providers), entry, key); + open_provider_form(cx, form); } Err(error) => { view.controller @@ -275,6 +278,45 @@ fn edit_provider( ); } +fn open_provider_form(cx: &mut Context<'_, ChatView, AlanAction>, form: ProviderOverlay) { + let form = cx.open_overlay(form); + cx.subscribe_once::(form, |saved, view, _form, cx| { + rebind_model(view, cx, saved.0.clone()); + }); +} + +/// A bound model keeps the provider it was bound from, so after an edit the +/// running model is bound again from the registered replacement. +fn rebind_model( + view: &mut ChatView, + cx: &mut Context<'_, ChatView, AlanAction>, + provider: ProviderId, +) { + let agent = view.controller.agent(); + let providers = Arc::clone(&view.providers); + cx.spawn( + async move { + let info = agent.info().await; + if info.provider != provider { + return Ok(()); + } + let model = bind_model_info(&providers, &info, agent.model_options().await)?; + agent + .set_model(model) + .await + .map_err(|e| TaskError(e.into())) + }, + |result, view, cx| { + if let Err(error) = result { + view.controller.push_info(format!( + "saved, but the running model still uses the old settings: {error}" + )); + cx.notify(); + } + }, + ); +} + /// The provider of the running model is swapped out first: the model picker /// opens without its models, and it goes once the switch succeeds. fn remove_provider( @@ -303,10 +345,12 @@ fn remove_idle_provider( cx: &mut Context<'_, ChatView, AlanAction>, name: ProviderName, ) { - let providers = view.custom_providers(); + let store = view.custom_provider_store(); + let providers = Arc::clone(&view.providers); cx.spawn( async move { - providers.remove(&name).await.map_err(super::to_task)?; + store.remove(&name).await.map_err(super::to_task)?; + providers.remove(&name.id()); Ok(name) }, |result, view, cx| { @@ -395,8 +439,8 @@ mod tests { #[test] fn picker_models_hides_only_the_replaced_provider() { let registry = ProviderRegistry::new([]); - registry.add_custom(provider("grok", &["grok-4"])); - registry.add_custom(provider("groq", &["llama"])); + registry.insert(provider("grok", &["grok-4"])); + registry.insert(provider("groq", &["llama"])); let all: Vec<_> = picker_models(®istry, None) .into_iter() diff --git a/crates/providers/src/custom.rs b/crates/providers/src/custom.rs index 5f28883..57c8367 100644 --- a/crates/providers/src/custom.rs +++ b/crates/providers/src/custom.rs @@ -3,10 +3,7 @@ use crate::{ ModelInfo, Provider, ProviderError, ProviderId, Resolved, ServerToolInfo, }; use async_trait::async_trait; -use llm::{ - ChatCompletionsApi, Credential as RequestCredential, HttpClient, LlmApi, LlmError, LlmRequest, - LlmStream, -}; +use llm::{ChatCompletionsApi, Credential as RequestCredential, HttpClient, LlmApi}; use serde::Deserialize; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -25,9 +22,7 @@ const CONTEXT_FIELDS: [&str; 4] = [ pub struct CustomProvider { id: ProviderId, api: ApiId, - /// Shared with the API client, so models already bound to this provider - /// follow [`set_base_url`](Self::set_base_url), as they follow key changes. - base_url: Arc>, + base_url: String, models: RwLock>, apis: HashMap>, auth: Arc, @@ -44,12 +39,12 @@ impl CustomProvider { credentials: Arc, models: Vec, ) -> Result { - let base_url = Arc::new(RwLock::new(base_url.into())); + let base_url = base_url.into(); let client: Arc = match &api { - ApiId::ChatCompletions => Arc::new(LiveUrlApi { - base_url: Arc::clone(&base_url), - http: Arc::new(HttpClient::new()), - }), + ApiId::ChatCompletions => Arc::new(ChatCompletionsApi::new( + base_url.clone(), + Arc::new(HttpClient::new()), + )), other => return Err(ProviderError::UnsupportedApi(other.clone())), }; Ok(Self { @@ -65,31 +60,6 @@ impl CustomProvider { id, }) } - - /// Point the provider, and every model already bound to it, at `base_url`. - pub fn set_base_url(&self, base_url: impl Into) { - *self.base_url.write().unwrap_or_else(|e| e.into_inner()) = base_url.into(); - } - - fn base_url(&self) -> String { - current(&self.base_url) - } -} - -/// Chat Completions against the provider's URL as it is when each request -/// starts. -struct LiveUrlApi { - base_url: Arc>, - http: Arc, -} - -#[async_trait] -impl LlmApi for LiveUrlApi { - async fn stream(&self, request: LlmRequest<'_>) -> Result { - ChatCompletionsApi::new(current(&self.base_url), Arc::clone(&self.http)) - .stream(request) - .await - } } #[async_trait] @@ -125,7 +95,7 @@ impl Provider for CustomProvider { RequestCredential::ApiKey(key) => Some(key), _ => None, }; - let models = fetch_catalog(&self.id, &self.api, &self.base_url(), key.as_deref()).await?; + let models = fetch_catalog(&self.id, &self.api, &self.base_url, key.as_deref()).await?; *self.models.write().unwrap_or_else(|e| e.into_inner()) = models; Ok(()) } @@ -144,7 +114,7 @@ impl Auth for CustomProvider { async fn validate_auth(&self, auth_result: &AuthResult) -> Result<(), AuthError> { match auth_result { AuthResult::ApiKey(key) => { - fetch_catalog(&self.id, &self.api, &self.base_url(), Some(key)) + fetch_catalog(&self.id, &self.api, &self.base_url, Some(key)) .await .map(drop) .map_err(|error| { @@ -159,10 +129,6 @@ impl Auth for CustomProvider { } } -fn current(base_url: &RwLock) -> String { - base_url.read().unwrap_or_else(|e| e.into_inner()).clone() -} - /// Reads the provider's key from the credential store. Without one, requests /// carry no `Authorization` header, which keyless local servers expect. struct StoredKeyAuth { diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 925571d..efbead7 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -1,7 +1,6 @@ use crate::Model; use crate::auth::AuthResolver; use crate::catalog::{ApiId, ModelInfo, ProviderId, ServerToolInfo}; -use crate::custom::CustomProvider; use crate::model::ModelOptions; use async_trait::async_trait; use llm::LlmApi; @@ -79,50 +78,28 @@ pub trait Provider: Auth + Send + Sync { } } -/// Every provider a model can bind to, in two tracks: the built-ins fixed at -/// construction, and custom providers added and removed at runtime. The -/// custom track keeps their type so an edit can reach them. +/// Every provider a model can bind to: the built-ins, then the custom +/// providers `/provider add` registers at runtime. pub struct ProviderRegistry { - builtin: Vec>, - custom: RwLock>>, + providers: RwLock>>, } impl ProviderRegistry { - pub fn new(builtin: impl IntoIterator>) -> Self { + pub fn new(providers: impl IntoIterator>) -> Self { Self { - builtin: builtin.into_iter().collect(), - custom: RwLock::new(Vec::new()), + providers: RwLock::new(providers.into_iter().collect()), } } - /// A snapshot of both tracks, built-ins first. pub fn providers(&self) -> Vec> { - let custom = self.custom.read().unwrap_or_else(|e| e.into_inner()); - self.builtin - .iter() - .cloned() - .chain( - custom - .iter() - .map(|provider| Arc::clone(provider) as Arc), - ) - .collect() + self.providers + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() } pub fn get(&self, id: &ProviderId) -> Option> { - self.builtin - .iter() - .find(|p| p.id() == *id) - .cloned() - .or_else(|| self.custom(id).map(|p| p as Arc)) - } - - pub fn is_builtin(&self, id: &ProviderId) -> bool { - self.builtin.iter().any(|p| p.id() == *id) - } - - pub fn custom(&self, id: &ProviderId) -> Option> { - self.custom + self.providers .read() .unwrap_or_else(|e| e.into_inner()) .iter() @@ -130,15 +107,17 @@ impl ProviderRegistry { .cloned() } - pub fn add_custom(&self, provider: Arc) { - self.custom - .write() - .unwrap_or_else(|e| e.into_inner()) - .push(provider); + /// Add `provider`, or replace the one with its id in place. + pub fn insert(&self, provider: Arc) { + let mut providers = self.providers.write().unwrap_or_else(|e| e.into_inner()); + match providers.iter_mut().find(|p| p.id() == provider.id()) { + Some(saved) => *saved = provider, + None => providers.push(provider), + } } - pub fn remove_custom(&self, id: &ProviderId) { - self.custom + pub fn remove(&self, id: &ProviderId) { + self.providers .write() .unwrap_or_else(|e| e.into_inner()) .retain(|provider| provider.id() != *id); @@ -175,56 +154,45 @@ mod tests { use super::*; use crate::{CustomProvider, InMemoryCredentialStore}; - fn custom(name: &str, model: &str) -> Arc { + fn provider(name: &str, url: &str) -> Arc { Arc::new( CustomProvider::new( ProviderId::new(name), ApiId::ChatCompletions, - "http://localhost:11434/v1", + url, Arc::new(InMemoryCredentialStore::new()), - vec![ModelInfo::new(model, ProviderId::new(name))], + vec![ModelInfo::new(url, ProviderId::new(name))], ) .unwrap(), ) } - // Any provider stands in for a built-in: only the track it sits in matters. - fn builtin(name: &str) -> Arc { - custom(name, "m") - } - - #[test] - fn providers_lists_builtins_before_custom() { - let registry = ProviderRegistry::new([builtin("openrouter")]); - registry.add_custom(custom("grok", "grok-4")); - - let ids: Vec<_> = registry.providers().iter().map(|p| p.id().0).collect(); - - assert_eq!(ids, ["openrouter", "grok"]); - } - - #[test] - fn each_lookup_sees_the_right_track() { - let registry = ProviderRegistry::new([builtin("openrouter")]); - registry.add_custom(custom("grok", "grok-4")); - - assert!(registry.get(&ProviderId::new("openrouter")).is_some()); - assert!(registry.get(&ProviderId::new("grok")).is_some()); - assert!(registry.is_builtin(&ProviderId::new("openrouter"))); - assert!(!registry.is_builtin(&ProviderId::new("grok"))); - assert!(registry.custom(&ProviderId::new("grok")).is_some()); - assert!(registry.custom(&ProviderId::new("openrouter")).is_none()); + fn ids_and_models(registry: &ProviderRegistry) -> Vec<(String, String)> { + registry + .providers() + .iter() + .map(|p| (p.id().0, p.models()[0].id.clone())) + .collect() } #[test] - fn remove_custom_leaves_builtins_alone() { - let registry = ProviderRegistry::new([builtin("openrouter")]); - registry.add_custom(custom("grok", "grok-4")); - - registry.remove_custom(&ProviderId::new("grok")); - registry.remove_custom(&ProviderId::new("openrouter")); - + fn insert_replaces_a_provider_in_place_and_remove_drops_it() { + let registry = ProviderRegistry::new([provider("openrouter", "a")]); + registry.insert(provider("grok", "b")); + registry.insert(provider("groq", "c")); + + registry.insert(provider("grok", "d")); + assert_eq!( + ids_and_models(®istry), + [ + ("openrouter".into(), "a".into()), + ("grok".into(), "d".into()), + ("groq".into(), "c".into()) + ] + ); + + registry.remove(&ProviderId::new("grok")); assert!(registry.get(&ProviderId::new("grok")).is_none()); - assert!(registry.get(&ProviderId::new("openrouter")).is_some()); + assert!(registry.get(&ProviderId::new("groq")).is_some()); } }