feat(ollama): add explicit Ollama mode config, cloud endpoint storage, and scope‑availability caching with status annotations.

This commit is contained in:
2025-10-15 10:05:34 +02:00
parent 5210e196f2
commit 708c626176
9 changed files with 1845 additions and 241 deletions

View File

@@ -6,14 +6,17 @@ use anyhow::{Context, Result, anyhow, bail};
use clap::Subcommand;
use owlen_core::LlmProvider;
use owlen_core::ProviderConfig;
use owlen_core::config as core_config;
use owlen_core::config::Config;
use owlen_core::config::{
self as core_config, Config, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
};
use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID};
use owlen_core::encryption;
use owlen_core::providers::OllamaProvider;
use owlen_core::storage::StorageManager;
use serde_json::Value;
const DEFAULT_CLOUD_ENDPOINT: &str = "https://ollama.com";
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
const CLOUD_ENDPOINT_KEY: &str = OLLAMA_CLOUD_ENDPOINT_KEY;
#[derive(Debug, Subcommand)]
pub enum CloudCommand {
@@ -28,6 +31,9 @@ pub enum CloudCommand {
/// Provider name to configure (default: ollama)
#[arg(long, default_value = "ollama")]
provider: String,
/// Overwrite the provider base URL with the cloud endpoint
#[arg(long)]
force_cloud_base_url: bool,
},
/// Check connectivity to Ollama Cloud
Status {
@@ -55,19 +61,29 @@ pub async fn run_cloud_command(command: CloudCommand) -> Result<()> {
api_key,
endpoint,
provider,
} => setup(provider, api_key, endpoint).await,
force_cloud_base_url,
} => setup(provider, api_key, endpoint, force_cloud_base_url).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<()> {
async fn setup(
provider: String,
api_key: Option<String>,
endpoint: Option<String>,
force_cloud_base_url: bool,
) -> 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());
let endpoint =
normalize_endpoint(&endpoint.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string()));
ensure_provider_entry(&mut config, &provider, &endpoint);
let base_changed = {
let entry = ensure_provider_entry(&mut config, &provider);
configure_cloud_endpoint(entry, &endpoint, force_cloud_base_url)
};
let key = match api_key {
Some(value) if !value.trim().is_empty() => value,
@@ -95,10 +111,6 @@ async fn setup(provider: String, api_key: Option<String>, endpoint: Option<Strin
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 {
@@ -106,6 +118,12 @@ async fn setup(provider: String, api_key: Option<String>, endpoint: Option<Strin
} else {
println!("API key stored in plaintext configuration (encryption disabled).");
}
if !force_cloud_base_url && !base_changed {
println!(
"Local base URL preserved; cloud endpoint stored as {}.",
CLOUD_ENDPOINT_KEY
);
}
Ok(())
}
@@ -120,25 +138,31 @@ async fn status(provider: String) -> Result<()> {
};
let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?;
ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT);
{
let entry = ensure_provider_entry(&mut config, &provider);
configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false);
}
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))
let endpoint =
resolve_cloud_endpoint(&provider_cfg).unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string());
let mut runtime_cfg = provider_cfg.clone();
runtime_cfg.base_url = Some(endpoint.clone());
runtime_cfg.extra.insert(
OLLAMA_MODE_KEY.to_string(),
Value::String("cloud".to_string()),
);
let ollama = OllamaProvider::from_config(&runtime_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)
);
println!("✓ Connected to {provider} ({})", endpoint);
if api_key.is_none() && config.privacy.encrypt_local_data {
println!(
"Warning: No API key stored; connection succeeded via environment variables."
@@ -164,13 +188,26 @@ async fn models(provider: String) -> Result<()> {
};
hydrate_api_key(&mut config, manager.as_ref()).await?;
ensure_provider_entry(&mut config, &provider, DEFAULT_CLOUD_ENDPOINT);
{
let entry = ensure_provider_entry(&mut config, &provider);
configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false);
}
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))
let endpoint =
resolve_cloud_endpoint(&provider_cfg).unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string());
let mut runtime_cfg = provider_cfg.clone();
runtime_cfg.base_url = Some(endpoint);
runtime_cfg.extra.insert(
OLLAMA_MODE_KEY.to_string(),
Value::String("cloud".to_string()),
);
let ollama = OllamaProvider::from_config(&runtime_cfg, Some(&config.general))
.with_context(|| "Failed to construct Ollama provider. Run `owlen cloud setup` first.")?;
match ollama.list_models().await {
@@ -217,7 +254,7 @@ async fn logout(provider: String) -> Result<()> {
Ok(())
}
fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
fn ensure_provider_entry<'a>(config: &'a mut Config, provider: &str) -> &'a mut ProviderConfig {
if provider == "ollama"
&& config.providers.contains_key("ollama-cloud")
&& !config.providers.contains_key("ollama")
@@ -230,13 +267,68 @@ fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
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());
}
let entry = config
.providers
.get_mut(provider)
.expect("provider entry must exist");
if entry.provider_type != "ollama" {
entry.provider_type = "ollama".to_string();
}
entry
}
fn configure_cloud_endpoint(entry: &mut ProviderConfig, endpoint: &str, force: bool) -> bool {
let normalized = normalize_endpoint(endpoint);
let previous_base = entry.base_url.clone();
entry.extra.insert(
CLOUD_ENDPOINT_KEY.to_string(),
Value::String(normalized.clone()),
);
if force
|| entry
.base_url
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
entry.base_url = Some(normalized.clone());
}
if force {
entry.extra.insert(
OLLAMA_MODE_KEY.to_string(),
Value::String("cloud".to_string()),
);
}
entry.base_url != previous_base
}
fn resolve_cloud_endpoint(cfg: &ProviderConfig) -> Option<String> {
if let Some(value) = cfg
.extra
.get(CLOUD_ENDPOINT_KEY)
.and_then(|value| value.as_str())
.map(normalize_endpoint)
{
return Some(value);
}
cfg.base_url
.as_ref()
.map(|value| value.trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
}
fn normalize_endpoint(endpoint: &str) -> String {
let trimmed = endpoint.trim().trim_end_matches('/');
if trimmed.is_empty() {
DEFAULT_CLOUD_ENDPOINT.to_string()
} else {
trimmed.to_string()
}
}
@@ -374,9 +466,7 @@ async fn hydrate_api_key(
let Some(cfg) = provider_entry_mut(config) else {
return Ok(Some(key));
};
if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() {
cfg.base_url = Some(credentials.endpoint.clone());
}
configure_cloud_endpoint(cfg, &credentials.endpoint, false);
return Ok(Some(key));
}