feat(cli): add provider management and model listing commands and integrate them into the CLI
This commit is contained in:
@@ -24,6 +24,7 @@ required-features = ["chat-client"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
owlen-core = { path = "../owlen-core" }
|
owlen-core = { path = "../owlen-core" }
|
||||||
|
owlen-providers = { path = "../owlen-providers" }
|
||||||
# Optional TUI dependency, enabled by the "chat-client" feature.
|
# Optional TUI dependency, enabled by the "chat-client" feature.
|
||||||
owlen-tui = { path = "../owlen-tui", optional = true }
|
owlen-tui = { path = "../owlen-tui", optional = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
|||||||
4
crates/owlen-cli/src/commands/mod.rs
Normal file
4
crates/owlen-cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
//! Command implementations for the `owlen` CLI.
|
||||||
|
|
||||||
|
pub mod cloud;
|
||||||
|
pub mod providers;
|
||||||
652
crates/owlen-cli/src/commands/providers.rs
Normal file
652
crates/owlen-cli/src/commands/providers.rs
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
use owlen_core::ProviderConfig;
|
||||||
|
use owlen_core::config::{self as core_config, Config};
|
||||||
|
use owlen_core::provider::{
|
||||||
|
AnnotatedModelInfo, ModelProvider, ProviderManager, ProviderStatus, ProviderType,
|
||||||
|
};
|
||||||
|
use owlen_core::storage::StorageManager;
|
||||||
|
use owlen_providers::ollama::{OllamaCloudProvider, OllamaLocalProvider};
|
||||||
|
use owlen_tui::config as tui_config;
|
||||||
|
|
||||||
|
use super::cloud;
|
||||||
|
|
||||||
|
/// CLI subcommands for provider management.
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum ProvidersCommand {
|
||||||
|
/// List configured providers and their metadata.
|
||||||
|
List,
|
||||||
|
/// Run health checks against providers.
|
||||||
|
Status {
|
||||||
|
/// Optional provider identifier to check.
|
||||||
|
#[arg(value_name = "PROVIDER")]
|
||||||
|
provider: Option<String>,
|
||||||
|
},
|
||||||
|
/// Enable a provider in the configuration.
|
||||||
|
Enable {
|
||||||
|
/// Provider identifier to enable.
|
||||||
|
provider: String,
|
||||||
|
},
|
||||||
|
/// Disable a provider in the configuration.
|
||||||
|
Disable {
|
||||||
|
/// Provider identifier to disable.
|
||||||
|
provider: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for the `owlen models` command.
|
||||||
|
#[derive(Debug, Default, Args)]
|
||||||
|
pub struct ModelsArgs {
|
||||||
|
/// Restrict output to a specific provider.
|
||||||
|
#[arg(long)]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> {
|
||||||
|
match command {
|
||||||
|
ProvidersCommand::List => list_providers(),
|
||||||
|
ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await,
|
||||||
|
ProvidersCommand::Enable { provider } => toggle_provider(&provider, true),
|
||||||
|
ProvidersCommand::Disable { provider } => toggle_provider(&provider, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_models_command(args: ModelsArgs) -> Result<()> {
|
||||||
|
list_models(args.provider.as_deref()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_providers() -> Result<()> {
|
||||||
|
let config = tui_config::try_load_config().unwrap_or_default();
|
||||||
|
let default_provider = canonical_provider_id(&config.general.default_provider);
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (id, cfg) in &config.providers {
|
||||||
|
let type_label = describe_provider_type(id, cfg);
|
||||||
|
let auth_label = describe_auth(cfg, requires_auth(id, cfg));
|
||||||
|
let enabled = if cfg.enabled { "yes" } else { "no" };
|
||||||
|
let default = if id == &default_provider { "*" } else { "" };
|
||||||
|
let base = cfg
|
||||||
|
.base_url
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
rows.push(ProviderListRow {
|
||||||
|
id: id.to_string(),
|
||||||
|
type_label,
|
||||||
|
enabled: enabled.to_string(),
|
||||||
|
default: default.to_string(),
|
||||||
|
auth: auth_label,
|
||||||
|
base_url: base,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
|
||||||
|
let id_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.id.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(8)
|
||||||
|
.max("Provider".len());
|
||||||
|
let enabled_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.enabled.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(7)
|
||||||
|
.max("Enabled".len());
|
||||||
|
let default_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.default.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(7)
|
||||||
|
.max("Default".len());
|
||||||
|
let type_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.type_label.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(4)
|
||||||
|
.max("Type".len());
|
||||||
|
let auth_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.auth.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(4)
|
||||||
|
.max("Auth".len());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<id_width$} {:<enabled_width$} {:<default_width$} {:<type_width$} {:<auth_width$} Base URL",
|
||||||
|
"Provider",
|
||||||
|
"Enabled",
|
||||||
|
"Default",
|
||||||
|
"Type",
|
||||||
|
"Auth",
|
||||||
|
id_width = id_width,
|
||||||
|
enabled_width = enabled_width,
|
||||||
|
default_width = default_width,
|
||||||
|
type_width = type_width,
|
||||||
|
auth_width = auth_width,
|
||||||
|
);
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
println!(
|
||||||
|
"{:<id_width$} {:<enabled_width$} {:<default_width$} {:<type_width$} {:<auth_width$} {}",
|
||||||
|
row.id,
|
||||||
|
row.enabled,
|
||||||
|
row.default,
|
||||||
|
row.type_label,
|
||||||
|
row.auth,
|
||||||
|
row.base_url,
|
||||||
|
id_width = id_width,
|
||||||
|
enabled_width = enabled_width,
|
||||||
|
default_width = default_width,
|
||||||
|
type_width = type_width,
|
||||||
|
auth_width = auth_width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_providers(filter: Option<&str>) -> Result<()> {
|
||||||
|
let mut config = tui_config::try_load_config().unwrap_or_default();
|
||||||
|
let filter = filter.map(canonical_provider_id);
|
||||||
|
verify_provider_filter(&config, filter.as_deref())?;
|
||||||
|
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
cloud::load_runtime_credentials(&mut config, storage.clone()).await?;
|
||||||
|
|
||||||
|
let manager = ProviderManager::new(&config);
|
||||||
|
let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?;
|
||||||
|
let health = manager.refresh_health().await;
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for record in records {
|
||||||
|
let status = health.get(&record.id).copied();
|
||||||
|
rows.push(ProviderStatusRow::from_record(record, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
print_status_rows(&rows);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(filter: Option<&str>) -> Result<()> {
|
||||||
|
let mut config = tui_config::try_load_config().unwrap_or_default();
|
||||||
|
let filter = filter.map(canonical_provider_id);
|
||||||
|
verify_provider_filter(&config, filter.as_deref())?;
|
||||||
|
|
||||||
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
|
cloud::load_runtime_credentials(&mut config, storage.clone()).await?;
|
||||||
|
|
||||||
|
let manager = ProviderManager::new(&config);
|
||||||
|
let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?;
|
||||||
|
let models = manager
|
||||||
|
.list_all_models()
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
let statuses = manager.provider_statuses().await;
|
||||||
|
|
||||||
|
print_models(records, models, statuses);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> {
|
||||||
|
if let Some(filter) = filter {
|
||||||
|
if !config.providers.contains_key(filter) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Provider '{}' is not defined in configuration.",
|
||||||
|
filter
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_provider(provider: &str, enable: bool) -> Result<()> {
|
||||||
|
let mut config = tui_config::try_load_config().unwrap_or_default();
|
||||||
|
let canonical = canonical_provider_id(provider);
|
||||||
|
if canonical.is_empty() {
|
||||||
|
return Err(anyhow!("Provider name cannot be empty."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous_default = config.general.default_provider.clone();
|
||||||
|
let previous_fallback_enabled = config.providers.get("ollama_local").map(|cfg| cfg.enabled);
|
||||||
|
|
||||||
|
let previous_enabled;
|
||||||
|
{
|
||||||
|
let entry = core_config::ensure_provider_config_mut(&mut config, &canonical);
|
||||||
|
previous_enabled = entry.enabled;
|
||||||
|
if previous_enabled == enable {
|
||||||
|
println!(
|
||||||
|
"Provider '{}' is already {}.",
|
||||||
|
canonical,
|
||||||
|
if enable { "enabled" } else { "disabled" }
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
entry.enabled = enable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !enable && config.general.default_provider == canonical {
|
||||||
|
if let Some(candidate) = choose_fallback_provider(&config, &canonical) {
|
||||||
|
config.general.default_provider = candidate.clone();
|
||||||
|
println!(
|
||||||
|
"Default provider set to '{}' because '{}' was disabled.",
|
||||||
|
candidate, canonical
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local");
|
||||||
|
entry.enabled = true;
|
||||||
|
config.general.default_provider = "ollama_local".to_string();
|
||||||
|
println!(
|
||||||
|
"Enabled 'ollama_local' and made it default because no other providers are active."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = config.validate() {
|
||||||
|
{
|
||||||
|
let entry = core_config::ensure_provider_config_mut(&mut config, &canonical);
|
||||||
|
entry.enabled = previous_enabled;
|
||||||
|
}
|
||||||
|
config.general.default_provider = previous_default;
|
||||||
|
if let Some(enabled) = previous_fallback_enabled {
|
||||||
|
if let Some(entry) = config.providers.get_mut("ollama_local") {
|
||||||
|
entry.enabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(anyhow!(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
tui_config::save_config(&config).map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} provider '{}'.",
|
||||||
|
if enable { "Enabled" } else { "Disabled" },
|
||||||
|
canonical
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_fallback_provider(config: &Config, exclude: &str) -> Option<String> {
|
||||||
|
if exclude != "ollama_local" {
|
||||||
|
if let Some(cfg) = config.providers.get("ollama_local") {
|
||||||
|
if cfg.enabled {
|
||||||
|
return Some("ollama_local".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates: Vec<String> = config
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, cfg)| cfg.enabled && id.as_str() != exclude)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
candidates.sort();
|
||||||
|
candidates.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_enabled_providers(
|
||||||
|
manager: &ProviderManager,
|
||||||
|
config: &Config,
|
||||||
|
filter: Option<&str>,
|
||||||
|
) -> Result<Vec<ProviderRecord>> {
|
||||||
|
let default_provider = canonical_provider_id(&config.general.default_provider);
|
||||||
|
let mut records = Vec::new();
|
||||||
|
|
||||||
|
for (id, cfg) in &config.providers {
|
||||||
|
if let Some(filter) = filter {
|
||||||
|
if id != filter {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut record = ProviderRecord::from_config(id, cfg, id == &default_provider);
|
||||||
|
if !cfg.enabled {
|
||||||
|
records.push(record);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match instantiate_provider(id, cfg) {
|
||||||
|
Ok(provider) => {
|
||||||
|
let metadata = provider.metadata().clone();
|
||||||
|
record.provider_type_label = provider_type_label(metadata.provider_type);
|
||||||
|
record.requires_auth = metadata.requires_auth;
|
||||||
|
record.metadata = Some(metadata);
|
||||||
|
manager.register_provider(provider).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
record.registration_error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
records.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instantiate_provider(id: &str, cfg: &ProviderConfig) -> Result<Arc<dyn ModelProvider>> {
|
||||||
|
let kind = cfg.provider_type.trim().to_ascii_lowercase();
|
||||||
|
if kind == "ollama" || id == "ollama_local" {
|
||||||
|
let provider = OllamaLocalProvider::new(cfg.base_url.clone(), None, None)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
Ok(Arc::new(provider))
|
||||||
|
} else if kind == "ollama_cloud" || id == "ollama_cloud" {
|
||||||
|
let provider = OllamaCloudProvider::new(cfg.base_url.clone(), cfg.api_key.clone(), None)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
Ok(Arc::new(provider))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"Provider '{}' uses unsupported type '{}'.",
|
||||||
|
id,
|
||||||
|
if kind.is_empty() {
|
||||||
|
"unknown"
|
||||||
|
} else {
|
||||||
|
kind.as_str()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe_provider_type(id: &str, cfg: &ProviderConfig) -> String {
|
||||||
|
if cfg.provider_type.trim().eq_ignore_ascii_case("ollama") || id.ends_with("_local") {
|
||||||
|
"Local".to_string()
|
||||||
|
} else if cfg
|
||||||
|
.provider_type
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("ollama_cloud")
|
||||||
|
|| id.contains("cloud")
|
||||||
|
{
|
||||||
|
"Cloud".to_string()
|
||||||
|
} else {
|
||||||
|
"Custom".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_auth(id: &str, cfg: &ProviderConfig) -> bool {
|
||||||
|
cfg.api_key.is_some()
|
||||||
|
|| cfg.api_key_env.is_some()
|
||||||
|
|| matches!(id, "ollama_cloud" | "openai" | "anthropic")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe_auth(cfg: &ProviderConfig, required: bool) -> String {
|
||||||
|
if let Some(env) = cfg
|
||||||
|
.api_key_env
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
format!("env:{env}")
|
||||||
|
} else if cfg
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"config".to_string()
|
||||||
|
} else if required {
|
||||||
|
"required".to_string()
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_provider_id(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim().to_ascii_lowercase();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
match trimmed.as_str() {
|
||||||
|
"ollama" | "ollama-local" => "ollama_local".to_string(),
|
||||||
|
"ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(),
|
||||||
|
other => other.replace('-', "_"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_type_label(provider_type: ProviderType) -> String {
|
||||||
|
match provider_type {
|
||||||
|
ProviderType::Local => "Local".to_string(),
|
||||||
|
ProviderType::Cloud => "Cloud".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_status_strings(status: ProviderStatus) -> (&'static str, &'static str) {
|
||||||
|
match status {
|
||||||
|
ProviderStatus::Available => ("OK", "available"),
|
||||||
|
ProviderStatus::Unavailable => ("ERR", "unavailable"),
|
||||||
|
ProviderStatus::RequiresSetup => ("SETUP", "requires setup"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_status_rows(rows: &[ProviderStatusRow]) {
|
||||||
|
let id_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.id.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(8)
|
||||||
|
.max("Provider".len());
|
||||||
|
let type_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.provider_type.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(4)
|
||||||
|
.max("Type".len());
|
||||||
|
let status_width = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.indicator.len() + 1 + row.status_label.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(6)
|
||||||
|
.max("State".len());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<id_width$} {:<4} {:<type_width$} {:<status_width$} Details",
|
||||||
|
"Provider",
|
||||||
|
"Def",
|
||||||
|
"Type",
|
||||||
|
"State",
|
||||||
|
id_width = id_width,
|
||||||
|
type_width = type_width,
|
||||||
|
status_width = status_width,
|
||||||
|
);
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let def = if row.default_provider { "*" } else { "-" };
|
||||||
|
let details = row.detail.as_deref().unwrap_or("-");
|
||||||
|
println!(
|
||||||
|
"{:<id_width$} {:<4} {:<type_width$} {:<status_width$} {}",
|
||||||
|
row.id,
|
||||||
|
def,
|
||||||
|
row.provider_type,
|
||||||
|
format!("{} {}", row.indicator, row.status_label),
|
||||||
|
details,
|
||||||
|
id_width = id_width,
|
||||||
|
type_width = type_width,
|
||||||
|
status_width = status_width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_models(
|
||||||
|
records: Vec<ProviderRecord>,
|
||||||
|
models: Vec<AnnotatedModelInfo>,
|
||||||
|
statuses: HashMap<String, ProviderStatus>,
|
||||||
|
) {
|
||||||
|
let mut grouped: HashMap<String, Vec<AnnotatedModelInfo>> = HashMap::new();
|
||||||
|
for info in models {
|
||||||
|
grouped
|
||||||
|
.entry(info.provider_id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
let status = statuses.get(&record.id).copied().or_else(|| {
|
||||||
|
if record.metadata.is_some() && record.registration_error.is_none() && record.enabled {
|
||||||
|
Some(ProviderStatus::Unavailable)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (indicator, label, status_value) = if !record.enabled {
|
||||||
|
("-", "disabled", None)
|
||||||
|
} else if record.registration_error.is_some() {
|
||||||
|
("ERR", "error", None)
|
||||||
|
} else if let Some(status) = status {
|
||||||
|
let (indicator, label) = provider_status_strings(status);
|
||||||
|
(indicator, label, Some(status))
|
||||||
|
} else {
|
||||||
|
("?", "unknown", None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if record.default_provider {
|
||||||
|
format!("{} (default)", record.id)
|
||||||
|
} else {
|
||||||
|
record.id.clone()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{} {} [{}] {}",
|
||||||
|
indicator, title, record.provider_type_label, label
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(err) = &record.registration_error {
|
||||||
|
println!(" error: {}", err);
|
||||||
|
println!();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !record.enabled {
|
||||||
|
println!(" provider disabled");
|
||||||
|
println!();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entries) = grouped.get(&record.id) {
|
||||||
|
let mut entries = entries.clone();
|
||||||
|
entries.sort_by(|a, b| a.model.name.cmp(&b.model.name));
|
||||||
|
if entries.is_empty() {
|
||||||
|
println!(" (no models reported)");
|
||||||
|
} else {
|
||||||
|
for entry in entries {
|
||||||
|
let mut line = format!(" - {}", entry.model.name);
|
||||||
|
if let Some(description) = &entry.model.description {
|
||||||
|
if !description.trim().is_empty() {
|
||||||
|
line.push_str(&format!(" — {}", description.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(" (no models reported)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ProviderStatus::RequiresSetup) = status_value {
|
||||||
|
if record.requires_auth {
|
||||||
|
println!(" configure provider credentials or API key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProviderListRow {
|
||||||
|
id: String,
|
||||||
|
type_label: String,
|
||||||
|
enabled: String,
|
||||||
|
default: String,
|
||||||
|
auth: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProviderRecord {
|
||||||
|
id: String,
|
||||||
|
enabled: bool,
|
||||||
|
default_provider: bool,
|
||||||
|
provider_type_label: String,
|
||||||
|
requires_auth: bool,
|
||||||
|
registration_error: Option<String>,
|
||||||
|
metadata: Option<owlen_core::provider::ProviderMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRecord {
|
||||||
|
fn from_config(id: &str, cfg: &ProviderConfig, default_provider: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.to_string(),
|
||||||
|
enabled: cfg.enabled,
|
||||||
|
default_provider,
|
||||||
|
provider_type_label: describe_provider_type(id, cfg),
|
||||||
|
requires_auth: requires_auth(id, cfg),
|
||||||
|
registration_error: None,
|
||||||
|
metadata: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProviderStatusRow {
|
||||||
|
id: String,
|
||||||
|
provider_type: String,
|
||||||
|
default_provider: bool,
|
||||||
|
indicator: String,
|
||||||
|
status_label: String,
|
||||||
|
detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderStatusRow {
|
||||||
|
fn from_record(record: ProviderRecord, status: Option<ProviderStatus>) -> Self {
|
||||||
|
if !record.enabled {
|
||||||
|
return Self {
|
||||||
|
id: record.id,
|
||||||
|
provider_type: record.provider_type_label,
|
||||||
|
default_provider: record.default_provider,
|
||||||
|
indicator: "-".to_string(),
|
||||||
|
status_label: "disabled".to_string(),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(err) = record.registration_error {
|
||||||
|
return Self {
|
||||||
|
id: record.id,
|
||||||
|
provider_type: record.provider_type_label,
|
||||||
|
default_provider: record.default_provider,
|
||||||
|
indicator: "ERR".to_string(),
|
||||||
|
status_label: "error".to_string(),
|
||||||
|
detail: Some(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = status {
|
||||||
|
let (indicator, label) = provider_status_strings(status);
|
||||||
|
return Self {
|
||||||
|
id: record.id,
|
||||||
|
provider_type: record.provider_type_label,
|
||||||
|
default_provider: record.default_provider,
|
||||||
|
indicator: indicator.to_string(),
|
||||||
|
status_label: label.to_string(),
|
||||||
|
detail: if matches!(status, ProviderStatus::RequiresSetup) && record.requires_auth {
|
||||||
|
Some("credentials required".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: record.id,
|
||||||
|
provider_type: record.provider_type_label,
|
||||||
|
default_provider: record.default_provider,
|
||||||
|
indicator: "?".to_string(),
|
||||||
|
status_label: "unknown".to_string(),
|
||||||
|
detail: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
//! OWLEN CLI - Chat TUI client
|
//! OWLEN CLI - Chat TUI client
|
||||||
|
|
||||||
mod cloud;
|
mod commands;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use cloud::{CloudCommand, load_runtime_credentials, set_env_var};
|
use commands::{
|
||||||
|
cloud::{CloudCommand, load_runtime_credentials, run_cloud_command, set_env_var},
|
||||||
|
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
|
||||||
|
};
|
||||||
use mcp::{McpCommand, run_mcp_command};
|
use mcp::{McpCommand, run_mcp_command};
|
||||||
use owlen_core::config as core_config;
|
use owlen_core::config as core_config;
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
@@ -58,6 +61,11 @@ enum OwlenCommand {
|
|||||||
/// Manage Ollama Cloud credentials
|
/// Manage Ollama Cloud credentials
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Cloud(CloudCommand),
|
Cloud(CloudCommand),
|
||||||
|
/// Manage model providers
|
||||||
|
#[command(subcommand)]
|
||||||
|
Providers(ProvidersCommand),
|
||||||
|
/// List models exposed by configured providers
|
||||||
|
Models(ModelsArgs),
|
||||||
/// Manage MCP server registrations
|
/// Manage MCP server registrations
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Mcp(McpCommand),
|
Mcp(McpCommand),
|
||||||
@@ -136,7 +144,9 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
|
|||||||
async fn run_command(command: OwlenCommand) -> Result<()> {
|
async fn run_command(command: OwlenCommand) -> Result<()> {
|
||||||
match command {
|
match command {
|
||||||
OwlenCommand::Config(config_cmd) => run_config_command(config_cmd),
|
OwlenCommand::Config(config_cmd) => run_config_command(config_cmd),
|
||||||
OwlenCommand::Cloud(cloud_cmd) => cloud::run_cloud_command(cloud_cmd).await,
|
OwlenCommand::Cloud(cloud_cmd) => run_cloud_command(cloud_cmd).await,
|
||||||
|
OwlenCommand::Providers(provider_cmd) => run_providers_command(provider_cmd).await,
|
||||||
|
OwlenCommand::Models(args) => run_models_command(args).await,
|
||||||
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
|
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
|
||||||
OwlenCommand::Upgrade => {
|
OwlenCommand::Upgrade => {
|
||||||
println!(
|
println!(
|
||||||
@@ -184,6 +194,68 @@ fn run_config_doctor() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = config.providers.get_mut("ollama_local") {
|
||||||
|
if entry.provider_type.trim().is_empty() || entry.provider_type != "ollama" {
|
||||||
|
entry.provider_type = "ollama".to_string();
|
||||||
|
changes.push("normalised providers.ollama_local.provider_type to 'ollama'".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ensure_default_enabled = true;
|
||||||
|
|
||||||
|
if !config.providers.values().any(|cfg| cfg.enabled) {
|
||||||
|
let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local");
|
||||||
|
if !entry.enabled {
|
||||||
|
entry.enabled = true;
|
||||||
|
changes.push("no providers were enabled; enabled 'ollama_local'".to_string());
|
||||||
|
}
|
||||||
|
if config.general.default_provider != "ollama_local" {
|
||||||
|
config.general.default_provider = "ollama_local".to_string();
|
||||||
|
changes.push(
|
||||||
|
"default provider reset to 'ollama_local' because no providers were enabled"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ensure_default_enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ensure_default_enabled {
|
||||||
|
let default_id = config.general.default_provider.clone();
|
||||||
|
if let Some(default_cfg) = config.providers.get(&default_id) {
|
||||||
|
if !default_cfg.enabled {
|
||||||
|
if let Some(new_default) = config
|
||||||
|
.providers
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, cfg)| cfg.enabled && *id != &default_id)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.min()
|
||||||
|
{
|
||||||
|
config.general.default_provider = new_default.clone();
|
||||||
|
changes.push(format!(
|
||||||
|
"default provider '{default_id}' was disabled; switched default to '{new_default}'"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let entry =
|
||||||
|
core_config::ensure_provider_config_mut(&mut config, "ollama_local");
|
||||||
|
if !entry.enabled {
|
||||||
|
entry.enabled = true;
|
||||||
|
changes.push(
|
||||||
|
"enabled 'ollama_local' because default provider was disabled"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if config.general.default_provider != "ollama_local" {
|
||||||
|
config.general.default_provider = "ollama_local".to_string();
|
||||||
|
changes.push(
|
||||||
|
"default provider reset to 'ollama_local' because previous default was disabled"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match config.mcp.mode {
|
match config.mcp.mode {
|
||||||
McpMode::Legacy => {
|
McpMode::Legacy => {
|
||||||
config.mcp.mode = McpMode::LocalOnly;
|
config.mcp.mode = McpMode::LocalOnly;
|
||||||
|
|||||||
Reference in New Issue
Block a user