refactor(ollama)!: remove Ollama provider crate and implementation

Deletes the `owlen-ollama` Cargo.toml and source files, fully removing the Ollama provider from the workspace. This aligns the project with the MCP‑only architecture and eliminates direct provider dependencies.
This commit is contained in:
2025-10-12 06:38:21 +02:00
parent 38aba1a6bb
commit 15e5c1206b
19 changed files with 1280 additions and 741 deletions

View File

@@ -26,7 +26,6 @@ required-features = ["chat-client"]
owlen-core = { path = "../owlen-core" }
# Optional TUI dependency, enabled by the "chat-client" feature.
owlen-tui = { path = "../owlen-tui", optional = true }
owlen-ollama = { path = "../owlen-ollama" }
log = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }

View File

@@ -0,0 +1,401 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use clap::Subcommand;
use owlen_core::config as core_config;
use owlen_core::config::Config;
use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID};
use owlen_core::encryption;
use owlen_core::provider::{LLMProvider, ProviderConfig};
use owlen_core::providers::OllamaProvider;
use owlen_core::storage::StorageManager;
const DEFAULT_CLOUD_ENDPOINT: &str = "https://ollama.com";
#[derive(Debug, Subcommand)]
pub enum CloudCommand {
/// Configure Ollama Cloud credentials
Setup {
/// API key passed directly on the command line (prompted when omitted)
#[arg(long)]
api_key: Option<String>,
/// Override the cloud endpoint (default: https://ollama.com)
#[arg(long)]
endpoint: Option<String>,
/// Provider name to configure (default: ollama)
#[arg(long, default_value = "ollama")]
provider: String,
},
/// Check connectivity to Ollama Cloud
Status {
/// Provider name to check (default: ollama)
#[arg(long, default_value = "ollama")]
provider: String,
},
/// List available cloud-hosted models
Models {
/// Provider name to query (default: ollama)
#[arg(long, default_value = "ollama")]
provider: String,
},
/// Remove stored Ollama Cloud credentials
Logout {
/// Provider name to clear (default: ollama)
#[arg(long, default_value = "ollama")]
provider: String,
},
}
pub async fn run_cloud_command(command: CloudCommand) -> Result<()> {
match command {
CloudCommand::Setup {
api_key,
endpoint,
provider,
} => setup(provider, api_key, endpoint).await,
CloudCommand::Status { provider } => status(provider).await,
CloudCommand::Models { provider } => models(provider).await,
CloudCommand::Logout { provider } => logout(provider).await,
}
}
async fn setup(provider: String, api_key: Option<String>, endpoint: Option<String>) -> Result<()> {
let provider = canonical_provider_name(&provider);
let mut config = crate::config::try_load_config().unwrap_or_default();
let endpoint = endpoint.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string());
ensure_provider_entry(&mut config, &provider, &endpoint);
let key = match api_key {
Some(value) if !value.trim().is_empty() => value,
_ => {
let prompt = format!("Enter API key for {provider}: ");
encryption::prompt_password(&prompt)?
}
};
if config.privacy.encrypt_local_data {
let storage = Arc::new(StorageManager::new().await?);
let manager = unlock_credential_manager(&config, storage.clone())?;
let credentials = ApiCredentials {
api_key: key.clone(),
endpoint: endpoint.clone(),
};
manager
.store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials)
.await?;
// Ensure plaintext key is not persisted to disk.
if let Some(entry) = config.providers.get_mut(&provider) {
entry.api_key = None;
}
} else if let Some(entry) = config.providers.get_mut(&provider) {
entry.api_key = Some(key.clone());
}
if let Some(entry) = config.providers.get_mut(&provider) {
entry.base_url = Some(endpoint.clone());
}
crate::config::save_config(&config)?;
println!("Saved Ollama configuration for provider '{provider}'.");
if config.privacy.encrypt_local_data {
println!("API key stored securely in the encrypted credential vault.");
} else {
println!("API key stored in plaintext configuration (encryption disabled).");
}
Ok(())
}
async fn status(provider: String) -> Result<()> {
let provider = canonical_provider_name(&provider);
let mut config = crate::config::try_load_config().unwrap_or_default();
let storage = Arc::new(StorageManager::new().await?);
let manager = if config.privacy.encrypt_local_data {
Some(unlock_credential_manager(&config, storage.clone())?)
} else {
None
};
let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?;
ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT);
let provider_cfg = config
.provider(&provider)
.cloned()
.ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?;
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
.with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?;
match ollama.health_check().await {
Ok(_) => {
println!(
"✓ Connected to {provider} ({})",
provider_cfg
.base_url
.as_deref()
.unwrap_or(DEFAULT_CLOUD_ENDPOINT)
);
if api_key.is_none() && config.privacy.encrypt_local_data {
println!(
"Warning: No API key stored; connection succeeded via environment variables."
);
}
}
Err(err) => {
println!("✗ Failed to reach {provider}: {err}");
}
}
Ok(())
}
async fn models(provider: String) -> Result<()> {
let provider = canonical_provider_name(&provider);
let mut config = crate::config::try_load_config().unwrap_or_default();
let storage = Arc::new(StorageManager::new().await?);
let manager = if config.privacy.encrypt_local_data {
Some(unlock_credential_manager(&config, storage.clone())?)
} else {
None
};
hydrate_api_key(&mut config, manager.as_ref()).await?;
ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT);
let provider_cfg = config
.provider(&provider)
.cloned()
.ok_or_else(|| anyhow!("Provider '{provider}' is not configured"))?;
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
.with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?;
match ollama.list_models().await {
Ok(models) => {
if models.is_empty() {
println!("No cloud models reported by '{}'.", provider);
} else {
println!("Models available via '{}':", provider);
for model in models {
if let Some(description) = &model.description {
println!(" - {} ({})", model.id, description);
} else {
println!(" - {}", model.id);
}
}
}
}
Err(err) => {
bail!("Failed to list models: {err}");
}
}
Ok(())
}
async fn logout(provider: String) -> Result<()> {
let provider = canonical_provider_name(&provider);
let mut config = crate::config::try_load_config().unwrap_or_default();
let storage = Arc::new(StorageManager::new().await?);
if config.privacy.encrypt_local_data {
let manager = unlock_credential_manager(&config, storage.clone())?;
manager
.delete_credentials(OLLAMA_CLOUD_CREDENTIAL_ID)
.await?;
}
if let Some(entry) = provider_entry_mut(&mut config) {
entry.api_key = None;
}
crate::config::save_config(&config)?;
println!("Cleared credentials for provider '{provider}'.");
Ok(())
}
fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
if provider == "ollama"
&& config.providers.contains_key("ollama-cloud")
&& !config.providers.contains_key("ollama")
{
if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
legacy.provider_type = "ollama".to_string();
config.providers.insert("ollama".to_string(), legacy);
}
}
core_config::ensure_provider_config(config, provider);
if let Some(cfg) = config.providers.get_mut(provider) {
if cfg.provider_type != "ollama" {
cfg.provider_type = "ollama".to_string();
}
if cfg.base_url.is_none() {
cfg.base_url = Some(endpoint.to_string());
}
}
}
fn canonical_provider_name(provider: &str) -> String {
let normalized = provider.trim().replace('_', "-").to_ascii_lowercase();
match normalized.as_str() {
"" => "ollama".to_string(),
"ollama-cloud" => "ollama".to_string(),
value => value.to_string(),
}
}
fn set_env_if_missing(var: &str, value: &str) {
if std::env::var(var)
.map(|v| v.trim().is_empty())
.unwrap_or(true)
{
std::env::set_var(var, value);
}
}
fn provider_entry_mut(config: &mut Config) -> Option<&mut ProviderConfig> {
if config.providers.contains_key("ollama") {
config.providers.get_mut("ollama")
} else {
config.providers.get_mut("ollama-cloud")
}
}
fn provider_entry(config: &Config) -> Option<&ProviderConfig> {
if let Some(entry) = config.providers.get("ollama") {
return Some(entry);
}
config.providers.get("ollama-cloud")
}
fn unlock_credential_manager(
config: &Config,
storage: Arc<StorageManager>,
) -> Result<Arc<CredentialManager>> {
if !config.privacy.encrypt_local_data {
bail!("Credential manager requested but encryption is disabled");
}
let secure_path = vault_path(&storage)?;
let handle = unlock_vault(&secure_path)?;
let master_key = Arc::new(handle.data.master_key.clone());
Ok(Arc::new(CredentialManager::new(
storage,
master_key.clone(),
)))
}
fn vault_path(storage: &StorageManager) -> Result<PathBuf> {
let base_dir = storage
.database_path()
.parent()
.map(|p| p.to_path_buf())
.or_else(dirs::data_local_dir)
.unwrap_or_else(|| PathBuf::from("."));
Ok(base_dir.join("encrypted_data.json"))
}
fn unlock_vault(path: &Path) -> Result<encryption::VaultHandle> {
use std::env;
if path.exists() {
if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD") {
if !password.trim().is_empty() {
return encryption::unlock_with_password(path.to_path_buf(), &password)
.context("Failed to unlock vault with OWLEN_MASTER_PASSWORD");
}
}
for attempt in 0..3 {
let password = encryption::prompt_password("Enter master password: ")?;
match encryption::unlock_with_password(path.to_path_buf(), &password) {
Ok(handle) => {
env::set_var("OWLEN_MASTER_PASSWORD", password);
return Ok(handle);
}
Err(err) => {
eprintln!("Failed to unlock vault: {err}");
if attempt == 2 {
return Err(err);
}
}
}
}
bail!("Unable to unlock encrypted credential vault");
}
let handle = encryption::unlock_interactive(path.to_path_buf())?;
if env::var("OWLEN_MASTER_PASSWORD")
.map(|v| v.trim().is_empty())
.unwrap_or(true)
{
let password = encryption::prompt_password("Cache master password for this session: ")?;
env::set_var("OWLEN_MASTER_PASSWORD", password);
}
Ok(handle)
}
async fn hydrate_api_key(
config: &mut Config,
manager: Option<&Arc<CredentialManager>>,
) -> Result<Option<String>> {
if let Some(manager) = manager {
if let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await? {
let key = credentials.api_key.trim().to_string();
if !key.is_empty() {
set_env_if_missing("OLLAMA_API_KEY", &key);
set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key);
}
if let Some(cfg) = provider_entry_mut(config) {
if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() {
cfg.base_url = Some(credentials.endpoint);
}
}
return Ok(Some(key));
}
}
if let Some(cfg) = provider_entry(config) {
if let Some(key) = cfg
.api_key
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
set_env_if_missing("OLLAMA_API_KEY", key);
set_env_if_missing("OLLAMA_CLOUD_API_KEY", key);
return Ok(Some(key.to_string()));
}
}
Ok(None)
}
pub async fn load_runtime_credentials(
config: &mut Config,
storage: Arc<StorageManager>,
) -> Result<()> {
if config.privacy.encrypt_local_data {
let manager = unlock_credential_manager(config, storage.clone())?;
hydrate_api_key(config, Some(&manager)).await?;
} else {
hydrate_api_key(config, None).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonicalises_provider_names() {
assert_eq!(canonical_provider_name("OLLAMA_CLOUD"), "ollama");
assert_eq!(canonical_provider_name(" ollama-cloud"), "ollama");
assert_eq!(canonical_provider_name(""), "ollama");
}
}

View File

@@ -1,20 +1,23 @@
//! OWLEN CLI - Chat TUI client
mod cloud;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use cloud::{load_runtime_credentials, CloudCommand};
use owlen_core::config as core_config;
use owlen_core::{
config::{Config, McpMode},
mcp::remote_client::RemoteMcpClient,
mode::Mode,
provider::ChatStream,
providers::OllamaProvider,
session::SessionController,
storage::StorageManager,
types::{ChatRequest, ChatResponse, Message, ModelInfo},
Error, Provider,
};
use owlen_ollama::OllamaProvider;
use owlen_tui::tui_controller::{TuiController, TuiRequest};
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
use std::borrow::Cow;
@@ -48,6 +51,9 @@ enum OwlenCommand {
/// Inspect or upgrade configuration files
#[command(subcommand)]
Config(ConfigCommand),
/// Manage Ollama Cloud credentials
#[command(subcommand)]
Cloud(CloudCommand),
/// Show manual steps for updating Owlen to the latest revision
Upgrade,
}
@@ -112,8 +118,7 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
match provider_cfg.provider_type.as_str() {
"ollama" | "ollama-cloud" => {
let provider = OllamaProvider::from_config(provider_cfg, Some(&cfg.general))?;
let provider: Arc<dyn Provider> = Arc::new(provider);
Ok(provider)
Ok(Arc::new(provider) as Arc<dyn Provider>)
}
other => Err(anyhow::anyhow!(format!(
"Provider type '{other}' is not supported in legacy/local MCP mode"
@@ -121,9 +126,10 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
}
}
fn run_command(command: OwlenCommand) -> Result<()> {
async fn run_command(command: OwlenCommand) -> Result<()> {
match command {
OwlenCommand::Config(config_cmd) => run_config_command(config_cmd),
OwlenCommand::Cloud(cloud_cmd) => cloud::run_cloud_command(cloud_cmd).await,
OwlenCommand::Upgrade => {
println!("To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force");
println!(
@@ -163,16 +169,34 @@ fn run_config_doctor() -> Result<()> {
changes.push("default provider missing; reset to 'ollama'".to_string());
}
if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
legacy.provider_type = "ollama".to_string();
use std::collections::hash_map::Entry;
match config.providers.entry("ollama".to_string()) {
Entry::Occupied(mut existing) => {
let entry = existing.get_mut();
if entry.api_key.is_none() {
entry.api_key = legacy.api_key.take();
}
if entry.base_url.is_none() && legacy.base_url.is_some() {
entry.base_url = legacy.base_url.take();
}
entry.extra.extend(legacy.extra);
}
Entry::Vacant(slot) => {
slot.insert(legacy);
}
}
changes.push(
"migrated legacy 'ollama-cloud' provider into unified 'ollama' entry".to_string(),
);
}
if !config.providers.contains_key("ollama") {
core_config::ensure_provider_config(&mut config, "ollama");
changes.push("added default ollama provider configuration".to_string());
}
if !config.providers.contains_key("ollama-cloud") {
core_config::ensure_provider_config(&mut config, "ollama-cloud");
changes.push("added default ollama-cloud provider configuration".to_string());
}
match config.mcp.mode {
McpMode::Legacy => {
config.mcp.mode = McpMode::LocalOnly;
@@ -329,7 +353,7 @@ async fn main() -> Result<()> {
// Parse command-line arguments
let Args { code, command } = Args::parse();
if let Some(command) = command {
return run_command(command);
return run_command(command).await;
}
let initial_mode = if code { Mode::Code } else { Mode::Chat };
@@ -339,8 +363,6 @@ async fn main() -> Result<()> {
let color_support = detect_terminal_color_support();
// Load configuration (or fall back to defaults) for the session controller.
let mut cfg = config::try_load_config().unwrap_or_default();
// Disable encryption for CLI to avoid password prompts in this environment.
cfg.privacy.encrypt_local_data = false;
if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) {
let term_label = match &color_support {
TerminalColorSupport::Limited { term } => Cow::from(term.as_str()),
@@ -357,6 +379,8 @@ async fn main() -> Result<()> {
);
}
cfg.validate()?;
let storage = Arc::new(StorageManager::new().await?);
load_runtime_credentials(&mut cfg, storage.clone()).await?;
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
let tui_controller = Arc::new(TuiController::new(tui_tx));
@@ -387,7 +411,6 @@ async fn main() -> Result<()> {
}
};
let storage = Arc::new(StorageManager::new().await?);
let controller =
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
let (mut app, mut session_rx) = ChatApp::new(controller).await?;