feat(config): separate Ollama into local/cloud providers, add OpenAI & Anthropic defaults, bump schema version to 1.6.0

This commit is contained in:
2025-10-15 22:13:00 +02:00
parent b49f58bc16
commit 282dcdce88
11 changed files with 591 additions and 300 deletions

View File

@@ -7,7 +7,8 @@ use clap::Subcommand;
use owlen_core::LlmProvider;
use owlen_core::ProviderConfig;
use owlen_core::config::{
self as core_config, Config, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
self as core_config, Config, OLLAMA_CLOUD_API_KEY_ENV, 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;
@@ -17,6 +18,7 @@ use serde_json::Value;
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
const CLOUD_ENDPOINT_KEY: &str = OLLAMA_CLOUD_ENDPOINT_KEY;
const CLOUD_PROVIDER_KEY: &str = "ollama_cloud";
#[derive(Debug, Subcommand)]
pub enum CloudCommand {
@@ -28,8 +30,8 @@ pub enum CloudCommand {
/// 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 name to configure (default: ollama_cloud)
#[arg(long, default_value = "ollama_cloud")]
provider: String,
/// Overwrite the provider base URL with the cloud endpoint
#[arg(long)]
@@ -37,20 +39,20 @@ pub enum CloudCommand {
},
/// Check connectivity to Ollama Cloud
Status {
/// Provider name to check (default: ollama)
#[arg(long, default_value = "ollama")]
/// Provider name to check (default: ollama_cloud)
#[arg(long, default_value = "ollama_cloud")]
provider: String,
},
/// List available cloud-hosted models
Models {
/// Provider name to query (default: ollama)
#[arg(long, default_value = "ollama")]
/// Provider name to query (default: ollama_cloud)
#[arg(long, default_value = "ollama_cloud")]
provider: String,
},
/// Remove stored Ollama Cloud credentials
Logout {
/// Provider name to clear (default: ollama)
#[arg(long, default_value = "ollama")]
/// Provider name to clear (default: ollama_cloud)
#[arg(long, default_value = "ollama_cloud")]
provider: String,
},
}
@@ -82,6 +84,7 @@ async fn setup(
let base_changed = {
let entry = ensure_provider_entry(&mut config, &provider);
entry.enabled = true;
configure_cloud_endpoint(entry, &endpoint, force_cloud_base_url)
};
@@ -140,6 +143,7 @@ async fn status(provider: String) -> Result<()> {
let api_key = hydrate_api_key(&mut config, manager.as_ref()).await?;
{
let entry = ensure_provider_entry(&mut config, &provider);
entry.enabled = true;
configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false);
}
@@ -190,6 +194,7 @@ async fn models(provider: String) -> Result<()> {
{
let entry = ensure_provider_entry(&mut config, &provider);
entry.enabled = true;
configure_cloud_endpoint(entry, DEFAULT_CLOUD_ENDPOINT, false);
}
@@ -245,8 +250,9 @@ async fn logout(provider: String) -> Result<()> {
.await?;
}
if let Some(entry) = provider_entry_mut(&mut config) {
if let Some(entry) = config.providers.get_mut(&provider) {
entry.api_key = None;
entry.enabled = false;
}
crate::config::save_config(&config)?;
@@ -255,28 +261,7 @@ async fn logout(provider: String) -> Result<()> {
}
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")
{
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);
let entry = config
.providers
.get_mut(provider)
.expect("provider entry must exist");
if entry.provider_type != "ollama" {
entry.provider_type = "ollama".to_string();
}
entry
core_config::ensure_provider_config_mut(config, provider)
}
fn configure_cloud_endpoint(entry: &mut ProviderConfig, endpoint: &str, force: bool) -> bool {
@@ -287,6 +272,10 @@ fn configure_cloud_endpoint(entry: &mut ProviderConfig, endpoint: &str, force: b
Value::String(normalized.clone()),
);
if entry.api_key_env.is_none() {
entry.api_key_env = Some(OLLAMA_CLOUD_API_KEY_ENV.to_string());
}
if force
|| entry
.base_url
@@ -298,10 +287,7 @@ fn configure_cloud_endpoint(entry: &mut ProviderConfig, endpoint: &str, force: b
}
if force {
entry.extra.insert(
OLLAMA_MODE_KEY.to_string(),
Value::String("cloud".to_string()),
);
entry.enabled = true;
}
entry.base_url != previous_base
@@ -333,10 +319,11 @@ fn normalize_endpoint(endpoint: &str) -> String {
}
fn canonical_provider_name(provider: &str) -> String {
let normalized = provider.trim().replace('_', "-").to_ascii_lowercase();
let normalized = provider.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"" => "ollama".to_string(),
"ollama-cloud" => "ollama".to_string(),
"" => CLOUD_PROVIDER_KEY.to_string(),
"ollama" => CLOUD_PROVIDER_KEY.to_string(),
"ollama_cloud" => CLOUD_PROVIDER_KEY.to_string(),
value => value.to_string(),
}
}
@@ -362,21 +349,6 @@ fn set_env_if_missing(var: &str, value: &str) {
}
}
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>,
@@ -463,14 +435,13 @@ async fn hydrate_api_key(
set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key);
}
let Some(cfg) = provider_entry_mut(config) else {
return Ok(Some(key));
};
let cfg = core_config::ensure_provider_config_mut(config, CLOUD_PROVIDER_KEY);
configure_cloud_endpoint(cfg, &credentials.endpoint, false);
return Ok(Some(key));
}
if let Some(key) = provider_entry(config)
if let Some(key) = config
.provider(CLOUD_PROVIDER_KEY)
.and_then(|cfg| cfg.api_key.as_ref())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
@@ -501,8 +472,8 @@ mod tests {
#[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");
assert_eq!(canonical_provider_name("OLLAMA_CLOUD"), CLOUD_PROVIDER_KEY);
assert_eq!(canonical_provider_name(" ollama-cloud"), CLOUD_PROVIDER_KEY);
assert_eq!(canonical_provider_name(""), CLOUD_PROVIDER_KEY);
}
}

View File

@@ -123,7 +123,7 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
})?;
match provider_cfg.provider_type.as_str() {
"ollama" | "ollama-cloud" => {
"ollama" | "ollama_cloud" => {
let provider = OllamaProvider::from_config(provider_cfg, Some(&cfg.general))?;
Ok(Arc::new(provider) as Arc<dyn Provider>)
}
@@ -172,40 +172,16 @@ fn run_config_doctor() -> Result<()> {
changes.push("created configuration file from defaults".to_string());
}
if !config
.providers
.contains_key(&config.general.default_provider)
{
config.general.default_provider = "ollama".to_string();
changes.push("default provider missing; reset to 'ollama'".to_string());
if config.provider(&config.general.default_provider).is_none() {
config.general.default_provider = "ollama_local".to_string();
changes.push("default provider missing; reset to 'ollama_local'".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);
}
for key in ["ollama_local", "ollama_cloud", "openai", "anthropic"] {
if !config.providers.contains_key(key) {
core_config::ensure_provider_config_mut(&mut config, key);
changes.push(format!("added default configuration for provider '{key}'"));
}
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());
}
match config.mcp.mode {

View File

@@ -16,7 +16,7 @@ use std::time::Duration;
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `config.toml`.
pub const CONFIG_SCHEMA_VERSION: &str = "1.5.0";
pub const CONFIG_SCHEMA_VERSION: &str = "1.6.0";
/// Provider config key for forcing Ollama provider mode.
pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
@@ -24,6 +24,18 @@ pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint";
/// Canonical Ollama Cloud base URL.
pub const OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com";
/// Environment variable used for Ollama Cloud authentication.
pub const OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
/// Default base URL for local Ollama daemons.
pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434";
/// Default OpenAI API base URL.
pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
/// Environment variable name used for OpenAI API keys.
pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
/// Default Anthropic API base URL.
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
/// Environment variable name used for Anthropic API keys.
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
/// Core configuration shared by all OWLEN clients
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -82,8 +94,7 @@ pub struct Config {
impl Default for Config {
fn default() -> Self {
let mut providers = HashMap::new();
providers.insert("ollama".to_string(), default_ollama_provider_config());
let providers = default_provider_configs();
Self {
schema_version: Self::default_schema_version(),
@@ -270,6 +281,8 @@ impl Config {
let content = fs::read_to_string(&path)?;
let parsed: toml::Value =
toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))?;
let mut parsed = parsed;
migrate_legacy_provider_tables(&mut parsed);
let previous_version = parsed
.get("schema_version")
.and_then(|value| value.as_str())
@@ -326,12 +339,19 @@ impl Config {
/// Get provider configuration by provider name
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
self.providers.get(name)
let key = normalize_provider_key(name);
self.providers.get(&key)
}
/// Update or insert a provider configuration
pub fn upsert_provider(&mut self, name: impl Into<String>, config: ProviderConfig) {
self.providers.insert(name.into(), config);
let raw = name.into();
let key = normalize_provider_key(&raw);
let mut config = config;
if config.provider_type.is_empty() {
config.provider_type = key.clone();
}
self.providers.insert(key, config);
}
/// Resolve default model in order of priority: explicit default, first cached model, provider fallback
@@ -353,11 +373,15 @@ impl Config {
}
fn ensure_defaults(&mut self) {
if self.general.default_provider.is_empty() {
self.general.default_provider = "ollama".to_string();
if self.general.default_provider.is_empty() || self.general.default_provider == "ollama" {
self.general.default_provider = "ollama_local".to_string();
}
let mut defaults = default_provider_configs();
for (name, default_cfg) in defaults.drain() {
self.providers.entry(name).or_insert(default_cfg);
}
ensure_provider_config(self, "ollama");
if self.schema_version.is_empty() {
self.schema_version = Self::default_schema_version();
}
@@ -561,6 +585,7 @@ impl Config {
self.validate_default_provider()?;
self.validate_mcp_settings()?;
self.validate_mcp_servers()?;
self.validate_providers()?;
Ok(())
}
@@ -573,57 +598,92 @@ impl Config {
);
}
if let Some(legacy_cloud) = self.providers.remove("ollama_cloud") {
self.merge_legacy_ollama_provider(legacy_cloud);
self.migrate_provider_entries();
if self.general.default_provider == "ollama" {
self.general.default_provider = "ollama_local".to_string();
}
if let Some(legacy_cloud) = self.providers.remove("ollama-cloud") {
self.merge_legacy_ollama_provider(legacy_cloud);
}
if let Some(ollama) = self.providers.get_mut("ollama") {
let previous_mode = ollama
.extra
.get(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str())
.map(|value| value.to_ascii_lowercase());
ensure_ollama_mode_extra(ollama);
if previous_mode.as_deref().unwrap_or("auto") == "auto"
&& is_cloud_base_url(ollama.base_url.as_ref())
{
ollama.extra.insert(
OLLAMA_MODE_KEY.to_string(),
serde_json::Value::String("cloud".to_string()),
);
}
}
self.ensure_defaults();
self.schema_version = CONFIG_SCHEMA_VERSION.to_string();
}
fn merge_legacy_ollama_provider(&mut self, mut legacy_cloud: ProviderConfig) {
use std::collections::hash_map::Entry;
fn migrate_provider_entries(&mut self) {
let mut migrated = default_provider_configs();
let legacy_entries = std::mem::take(&mut self.providers);
legacy_cloud.provider_type = "ollama".to_string();
match self.providers.entry("ollama".to_string()) {
Entry::Occupied(mut entry) => {
let target = entry.get_mut();
if target.base_url.is_none() {
target.base_url = legacy_cloud.base_url.take();
}
if target.api_key.is_none() {
target.api_key = legacy_cloud.api_key.take();
}
if target.extra.is_empty() && !legacy_cloud.extra.is_empty() {
target.extra = legacy_cloud.extra;
}
ensure_ollama_mode_extra(target);
for (original_key, mut legacy) in legacy_entries {
if original_key == "ollama" {
Self::merge_legacy_ollama_provider(legacy, &mut migrated);
continue;
}
Entry::Vacant(entry) => {
let mut inserted = legacy_cloud;
ensure_ollama_mode_extra(&mut inserted);
entry.insert(inserted);
let normalized = normalize_provider_key(&original_key);
let entry = migrated
.entry(normalized.clone())
.or_insert_with(|| ProviderConfig {
enabled: true,
provider_type: normalized.clone(),
base_url: None,
api_key: None,
api_key_env: None,
extra: HashMap::new(),
});
if legacy.provider_type.is_empty() {
legacy.provider_type = normalized.clone();
}
entry.merge_from(legacy);
if entry.provider_type.is_empty() {
entry.provider_type = normalized;
}
}
self.providers = migrated;
}
fn merge_legacy_ollama_provider(
mut legacy: ProviderConfig,
targets: &mut HashMap<String, ProviderConfig>,
) {
let mode = legacy
.extra
.remove(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str().map(|s| s.trim().to_ascii_lowercase()));
let api_key_present = legacy
.api_key
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let cloud_candidate =
matches!(mode.as_deref(), Some("cloud")) || is_cloud_base_url(legacy.base_url.as_ref());
let should_enable_cloud = cloud_candidate || api_key_present;
if matches!(mode.as_deref(), Some("local")) || !should_enable_cloud {
if let Some(local) = targets.get_mut("ollama_local") {
let mut copy = legacy.clone();
copy.api_key = None;
copy.api_key_env = None;
copy.enabled = true;
local.merge_from(copy);
local.enabled = true;
if local.base_url.is_none() {
local.base_url = Some(OLLAMA_LOCAL_BASE_URL.to_string());
}
}
}
if should_enable_cloud || matches!(mode.as_deref(), Some("cloud")) {
if let Some(cloud) = targets.get_mut("ollama_cloud") {
legacy.enabled = true;
cloud.merge_from(legacy);
cloud.enabled = true;
if cloud.base_url.is_none() {
cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
if cloud.api_key_env.is_none() {
cloud.api_key_env = Some(OLLAMA_CLOUD_API_KEY_ENV.to_string());
}
}
}
}
@@ -693,40 +753,164 @@ impl Config {
Ok(())
}
fn validate_providers(&self) -> Result<()> {
for (name, provider) in &self.providers {
if !provider.enabled {
continue;
}
match name.as_str() {
"ollama_local" => {
if is_blank(&provider.base_url) {
return Err(Error::Config(
"providers.ollama_local.base_url must be set when enabled".into(),
));
}
}
"ollama_cloud" => {
if is_blank(&provider.base_url) {
return Err(Error::Config(
"providers.ollama_cloud.base_url must be set when enabled".into(),
));
}
if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) {
return Err(Error::Config(
"providers.ollama_cloud requires `api_key` or `api_key_env` when enabled"
.into(),
));
}
}
"openai" | "anthropic" => {
if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) {
return Err(Error::Config(format!(
"providers.{name} requires `api_key` or `api_key_env` when enabled"
)));
}
}
_ => {}
}
}
Ok(())
}
}
fn default_ollama_provider_config() -> ProviderConfig {
let mut config = ProviderConfig {
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
fn default_provider_configs() -> HashMap<String, ProviderConfig> {
let mut providers = HashMap::new();
for name in ["ollama_local", "ollama_cloud", "openai", "anthropic"] {
if let Some(config) = default_provider_config_for(name) {
providers.insert(name.to_string(), config);
}
}
providers
}
fn default_ollama_local_config() -> ProviderConfig {
ProviderConfig {
enabled: true,
provider_type: canonical_provider_type("ollama_local"),
base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()),
api_key: None,
api_key_env: None,
extra: HashMap::new(),
};
ensure_ollama_mode_extra(&mut config);
config
}
}
fn ensure_ollama_mode_extra(provider: &mut ProviderConfig) {
if provider.provider_type != "ollama" {
fn default_ollama_cloud_config() -> ProviderConfig {
let mut extra = HashMap::new();
extra.insert(
OLLAMA_CLOUD_ENDPOINT_KEY.to_string(),
serde_json::Value::String(OLLAMA_CLOUD_BASE_URL.to_string()),
);
ProviderConfig {
enabled: false,
provider_type: canonical_provider_type("ollama_cloud"),
base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(OLLAMA_CLOUD_API_KEY_ENV.to_string()),
extra,
}
}
fn default_openai_config() -> ProviderConfig {
ProviderConfig {
enabled: false,
provider_type: canonical_provider_type("openai"),
base_url: Some(OPENAI_DEFAULT_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(OPENAI_API_KEY_ENV.to_string()),
extra: HashMap::new(),
}
}
fn default_anthropic_config() -> ProviderConfig {
ProviderConfig {
enabled: false,
provider_type: canonical_provider_type("anthropic"),
base_url: Some(ANTHROPIC_DEFAULT_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(ANTHROPIC_API_KEY_ENV.to_string()),
extra: HashMap::new(),
}
}
fn default_provider_config_for(name: &str) -> Option<ProviderConfig> {
match name {
"ollama_local" => Some(default_ollama_local_config()),
"ollama_cloud" => Some(default_ollama_cloud_config()),
"openai" => Some(default_openai_config()),
"anthropic" => Some(default_anthropic_config()),
_ => None,
}
}
fn normalize_provider_key(name: &str) -> String {
let normalized = name.trim().to_ascii_lowercase();
match normalized.as_str() {
"ollama" | "ollama-local" => "ollama_local".to_string(),
"ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(),
other => other.replace('-', "_"),
}
}
fn canonical_provider_type(key: &str) -> String {
match key {
"ollama_local" => "ollama".to_string(),
other => other.to_string(),
}
}
fn is_blank(value: &Option<String>) -> bool {
value.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true)
}
fn migrate_legacy_provider_tables(document: &mut toml::Value) {
let Some(table) = document.as_table_mut() else {
return;
};
let mut legacy = Vec::new();
for key in ["ollama", "ollama_cloud", "ollama-cloud"] {
if let Some(entry) = table.remove(key) {
legacy.push((key.to_string(), entry));
}
}
if legacy.is_empty() {
return;
}
let entry = provider
.extra
.entry(OLLAMA_MODE_KEY.to_string())
.or_insert_with(|| serde_json::Value::String("auto".to_string()));
let providers_entry = table
.entry("providers".to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
if let Some(value) = entry.as_str() {
let normalized = value.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "auto" | "local" | "cloud") {
if normalized != value {
*entry = serde_json::Value::String(normalized);
}
} else {
*entry = serde_json::Value::String("auto".to_string());
if let Some(providers_table) = providers_entry.as_table_mut() {
for (key, value) in legacy {
providers_table.insert(key, value);
}
} else {
*entry = serde_json::Value::String("auto".to_string());
}
}
@@ -1117,7 +1301,7 @@ impl GeneralSettings {
impl Default for GeneralSettings {
fn default() -> Self {
Self {
default_provider: "ollama".to_string(),
default_provider: "ollama_local".to_string(),
default_model: Some("llama3.2:latest".to_string()),
enable_streaming: Self::default_streaming(),
project_context_file: Some("OWLEN.md".to_string()),
@@ -1650,7 +1834,35 @@ impl Default for InputSettings {
/// Convenience accessor for an Ollama provider entry, creating a default if missing
pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
ensure_provider_config(config, "ollama")
ensure_provider_config(config, "ollama_local")
}
/// Ensure a provider configuration exists for the requested provider name and return a mutable reference.
pub fn ensure_provider_config_mut<'a>(
config: &'a mut Config,
provider_name: &str,
) -> &'a mut ProviderConfig {
let key = normalize_provider_key(provider_name);
let entry = config.providers.entry(key.clone()).or_insert_with(|| {
let mut default = default_provider_config_for(&key).unwrap_or_else(|| ProviderConfig {
enabled: true,
provider_type: canonical_provider_type(&key),
base_url: None,
api_key: None,
api_key_env: None,
extra: HashMap::new(),
});
if default.provider_type.is_empty() {
default.provider_type = canonical_provider_type(&key);
}
default
});
if entry.provider_type.is_empty() {
entry.provider_type = canonical_provider_type(&key);
}
entry
}
/// Ensure a provider configuration exists for the requested provider name
@@ -1658,35 +1870,8 @@ pub fn ensure_provider_config<'a>(
config: &'a mut Config,
provider_name: &str,
) -> &'a ProviderConfig {
use std::collections::hash_map::Entry;
if matches!(provider_name, "ollama_cloud" | "ollama-cloud") {
return ensure_provider_config(config, "ollama");
}
match config.providers.entry(provider_name.to_string()) {
Entry::Occupied(mut entry) => {
ensure_ollama_mode_extra(entry.get_mut());
}
Entry::Vacant(entry) => {
let mut default = match provider_name {
"ollama" => default_ollama_provider_config(),
other => ProviderConfig {
provider_type: other.to_string(),
base_url: None,
api_key: None,
extra: HashMap::new(),
},
};
ensure_ollama_mode_extra(&mut default);
entry.insert(default);
}
}
config
.providers
.get(provider_name)
.expect("provider entry must exist")
let entry = ensure_provider_config_mut(config, provider_name);
&*entry
}
/// Calculate absolute timeout for session data based on configuration
@@ -1705,8 +1890,8 @@ mod tests {
}
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") {
ollama.api_key = Some("${OWLEN_TEST_API_KEY}".to_string());
if let Some(ollama_local) = config.providers.get_mut("ollama_local") {
ollama_local.api_key = Some("${OWLEN_TEST_API_KEY}".to_string());
}
config
@@ -1714,7 +1899,7 @@ mod tests {
.expect("environment expansion succeeded");
assert_eq!(
config.providers["ollama"].api_key.as_deref(),
config.providers["ollama_local"].api_key.as_deref(),
Some("super-secret")
);
@@ -1730,8 +1915,8 @@ mod tests {
}
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") {
ollama.api_key = Some("${OWLEN_TEST_MISSING}".to_string());
if let Some(ollama_local) = config.providers.get_mut("ollama_local") {
ollama_local.api_key = Some("${OWLEN_TEST_MISSING}".to_string());
}
let error = config
@@ -1792,15 +1977,19 @@ mod tests {
#[test]
fn default_config_contains_local_provider() {
let config = Config::default();
assert!(config.providers.contains_key("ollama"));
let provider = config.providers.get("ollama").unwrap();
assert_eq!(
provider
.extra
.get(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str()),
Some("auto")
);
let local = config
.providers
.get("ollama_local")
.expect("default local provider");
assert!(local.enabled);
assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL));
let cloud = config
.providers
.get("ollama_cloud")
.expect("default cloud provider");
assert!(!cloud.enabled);
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
}
#[test]
@@ -1808,16 +1997,10 @@ mod tests {
let mut config = Config::default();
config.providers.clear();
let cloud = ensure_provider_config(&mut config, "ollama-cloud");
assert_eq!(cloud.provider_type, "ollama");
assert_eq!(cloud.base_url.as_deref(), Some("http://localhost:11434"));
assert_eq!(
cloud
.extra
.get(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str()),
Some("auto")
);
assert!(config.providers.contains_key("ollama"));
assert_eq!(cloud.provider_type, "ollama_cloud");
assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL));
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
assert!(config.providers.contains_key("ollama_cloud"));
assert!(!config.providers.contains_key("ollama-cloud"));
}
@@ -1828,48 +2011,100 @@ mod tests {
config.providers.insert(
"ollama_cloud".to_string(),
ProviderConfig {
enabled: true,
provider_type: "ollama_cloud".to_string(),
base_url: Some("https://api.ollama.com".to_string()),
api_key: Some("secret".to_string()),
api_key_env: None,
extra: HashMap::new(),
},
);
config.apply_schema_migrations("1.0.0");
assert!(config.providers.get("ollama_cloud").is_none());
assert!(config.providers.get("ollama-cloud").is_none());
let cloud = config.providers.get("ollama").expect("migrated config");
assert_eq!(cloud.provider_type, "ollama");
assert!(config.providers.get("ollama_cloud").is_some());
let cloud = config
.providers
.get("ollama_cloud")
.expect("migrated config");
assert!(cloud.enabled);
assert_eq!(cloud.provider_type, "ollama_cloud");
assert_eq!(cloud.base_url.as_deref(), Some("https://api.ollama.com"));
assert_eq!(cloud.api_key.as_deref(), Some("secret"));
assert_eq!(
cloud
.extra
.get(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str()),
Some("auto")
);
}
#[test]
fn migration_sets_cloud_mode_for_cloud_base() {
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") {
if let Some(ollama) = config.providers.get_mut("ollama_local") {
ollama.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
ollama.extra.remove(OLLAMA_MODE_KEY);
}
config.apply_schema_migrations("1.4.0");
let provider = config.providers.get("ollama").expect("ollama provider");
assert_eq!(
provider
.extra
.get(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str()),
Some("cloud")
let cloud = config
.providers
.get("ollama_cloud")
.expect("cloud provider created");
assert!(cloud.enabled);
assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL));
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
}
#[test]
fn migrate_legacy_monolithic_ollama_entry() {
let mut config = Config::default();
config.providers.clear();
config.providers.insert(
"ollama".to_string(),
ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()),
api_key: None,
api_key_env: None,
extra: HashMap::new(),
},
);
config.apply_schema_migrations("1.2.0");
let local = config
.providers
.get("ollama_local")
.expect("local provider migrated");
assert!(local.enabled);
assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL));
let cloud = config
.providers
.get("ollama_cloud")
.expect("cloud provider placeholder");
assert!(!cloud.enabled);
}
#[test]
fn migrate_legacy_provider_tables_moves_top_level_entries() {
let mut document: toml::Value = toml::from_str(
r#"
[ollama]
base_url = "http://localhost:11434"
[general]
default_provider = "ollama"
"#,
)
.expect("valid inline config");
migrate_legacy_provider_tables(&mut document);
let providers = document
.get("providers")
.and_then(|value| value.as_table())
.expect("providers table present");
assert!(providers.contains_key("ollama"));
assert!(providers["ollama"].get("base_url").is_some());
assert!(document.get("ollama").is_none());
}
#[test]

View File

@@ -144,17 +144,57 @@ where
/// Runtime configuration for a provider instance.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProviderConfig {
/// Provider type identifier.
/// Whether this provider should be activated.
#[serde(default = "ProviderConfig::default_enabled")]
pub enabled: bool,
/// Provider type identifier used to resolve implementations.
#[serde(default)]
pub provider_type: String,
/// Base URL for API calls.
#[serde(default)]
pub base_url: Option<String>,
/// API key or token material.
#[serde(default)]
pub api_key: Option<String>,
/// Environment variable holding the API key.
#[serde(default)]
pub api_key_env: Option<String>,
/// Additional provider-specific configuration.
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
impl ProviderConfig {
const fn default_enabled() -> bool {
true
}
/// Merge the current configuration with overrides from `other`.
pub fn merge_from(&mut self, mut other: ProviderConfig) {
self.enabled = other.enabled;
if !other.provider_type.is_empty() {
self.provider_type = other.provider_type;
}
if let Some(base_url) = other.base_url.take() {
self.base_url = Some(base_url);
}
if let Some(api_key) = other.api_key.take() {
self.api_key = Some(api_key);
}
if let Some(api_key_env) = other.api_key_env.take() {
self.api_key_env = Some(api_key_env);
}
if !other.extra.is_empty() {
self.extra.extend(other.extra);
}
}
}
/// Static registry of providers available to the application.
pub struct ProviderRegistry {
providers: HashMap<String, Arc<dyn Provider>>,

View File

@@ -1467,9 +1467,11 @@ mod tests {
#[test]
fn explicit_local_mode_overrides_api_key() {
let mut config = ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: Some("secret-key".to_string()),
api_key_env: None,
extra: HashMap::new(),
};
config.extra.insert(
@@ -1486,9 +1488,11 @@ mod tests {
#[test]
fn auto_mode_prefers_explicit_local_base() {
let config = ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: Some("secret-key".to_string()),
api_key_env: None,
extra: HashMap::new(),
};
// simulate missing explicit mode; defaults to auto
@@ -1502,9 +1506,11 @@ mod tests {
#[test]
fn auto_mode_with_api_key_and_no_local_probe_switches_to_cloud() {
let mut config = ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: None,
api_key: Some("secret-key".to_string()),
api_key_env: None,
extra: HashMap::new(),
};
config.extra.insert(
@@ -1580,9 +1586,11 @@ fn auto_mode_with_api_key_and_successful_probe_prefers_local() {
let _guard = ProbeOverrideGuard::set(Some(true));
let mut config = ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: None,
api_key: Some("secret-key".to_string()),
api_key_env: None,
extra: HashMap::new(),
};
config.extra.insert(
@@ -1603,9 +1611,11 @@ fn auto_mode_with_api_key_and_failed_probe_prefers_cloud() {
let _guard = ProbeOverrideGuard::set(Some(false));
let mut config = ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: None,
api_key: Some("secret-key".to_string()),
api_key_env: None,
extra: HashMap::new(),
};
config.extra.insert(
@@ -1622,9 +1632,11 @@ fn auto_mode_with_api_key_and_failed_probe_prefers_cloud() {
#[test]
fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() {
let config = ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: None,
api_key_env: None,
extra: HashMap::new(),
};

View File

@@ -126,7 +126,7 @@ fn provider_from_config() -> Result<Arc<dyn Provider>, RpcError> {
})?;
match provider_cfg.provider_type.as_str() {
"ollama" | "ollama-cloud" => {
"ollama" | "ollama_cloud" => {
let provider = OllamaProvider::from_config(&provider_cfg, Some(&config.general))
.map_err(|e| {
RpcError::internal_error(format!(
@@ -153,10 +153,12 @@ fn create_provider() -> Result<Arc<dyn Provider>, RpcError> {
}
fn canonical_provider_name(name: &str) -> String {
if name.eq_ignore_ascii_case("ollama-cloud") {
"ollama".to_string()
} else {
name.to_string()
let normalized = name.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"" => "ollama_local".to_string(),
"ollama" | "ollama_local" => "ollama_local".to_string(),
"ollama_cloud" => "ollama_cloud".to_string(),
other => other.to_string(),
}
}

View File

@@ -41,7 +41,9 @@ use crate::state::{
use crate::toast::{Toast, ToastLevel, ToastManager};
use crate::ui::format_tool_output;
use crate::{commands, highlight};
use owlen_core::config::{OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY};
use owlen_core::config::{
OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
};
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
@@ -500,7 +502,7 @@ impl ChatApp {
models: Vec::new(),
provider_scope_status: HashMap::new(),
available_providers: Vec::new(),
selected_provider: "ollama".to_string(), // Default, will be updated in initialize_models
selected_provider: "ollama_local".to_string(), // Default, will be updated in initialize_models
selected_provider_index: 0,
selected_model_item: None,
model_selector_items: Vec::new(),
@@ -4297,7 +4299,7 @@ impl ChatApp {
self.recompute_available_providers();
if self.available_providers.is_empty() {
self.available_providers.push("ollama".to_string());
self.available_providers.push("ollama_local".to_string());
}
if !config_model_provider.is_empty() {
@@ -7419,14 +7421,14 @@ impl ChatApp {
for (name, provider_cfg) in provider_entries {
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
if provider_type != "ollama" && provider_type != "ollama-cloud" {
if provider_type != "ollama" && provider_type != "ollama_cloud" {
continue;
}
let canonical_name = if name.eq_ignore_ascii_case("ollama-cloud") {
"ollama".to_string()
} else {
name.clone()
let canonical_name = match name.trim().to_ascii_lowercase().as_str() {
"ollama" | "ollama_local" => "ollama_local".to_string(),
"ollama-cloud" | "ollama_cloud" => "ollama_cloud".to_string(),
other => other.to_string(),
};
// All providers communicate via MCP LLM server (Phase 10).
@@ -7599,7 +7601,10 @@ impl ChatApp {
for (idx, model) in entries {
let canonical = model.id.to_string();
let is_cloud_id = model.id.ends_with("-cloud");
let priority = if matches!(provider_lower, "ollama" | "ollama-cloud") {
let priority = if matches!(
provider_lower,
"ollama" | "ollama_local" | "ollama-cloud" | "ollama_cloud"
) {
match scope {
ModelScope::Local => {
if is_cloud_id {
@@ -7651,7 +7656,7 @@ impl ChatApp {
}
if providers.is_empty() {
providers.insert("ollama".to_string());
providers.insert("ollama_local".to_string());
}
self.available_providers = providers.into_iter().collect();
@@ -7693,7 +7698,7 @@ impl ChatApp {
let mut items = Vec::new();
if self.available_providers.is_empty() {
items.push(ModelSelectorItem::header("ollama", false));
items.push(ModelSelectorItem::header("ollama_local", false));
self.model_selector_items = items;
return;
}
@@ -8074,7 +8079,7 @@ impl ChatApp {
self.set_model_info_visible(false);
self.recompute_available_providers();
if self.available_providers.is_empty() {
self.available_providers.push("ollama".to_string());
self.available_providers.push("ollama_local".to_string());
}
self.rebuild_model_selector_items();
self.selected_model_item = None;
@@ -8093,7 +8098,7 @@ impl ChatApp {
self.recompute_available_providers();
if self.available_providers.is_empty() {
self.available_providers.push("ollama".to_string());
self.available_providers.push("ollama_local".to_string());
}
if !config_model_provider.is_empty() {
@@ -8248,8 +8253,10 @@ impl ChatApp {
let (existing_plain_api_key, normalized_endpoint_local, base_overridden_local) =
if let Some(entry) = config.providers.get_mut(&options.provider) {
let existing = entry.api_key.clone();
if entry.provider_type != "ollama" {
entry.provider_type = "ollama".to_string();
entry.enabled = true;
entry.provider_type = "ollama_cloud".to_string();
if entry.api_key_env.is_none() {
entry.api_key_env = Some(OLLAMA_CLOUD_API_KEY_ENV.to_string());
}
let requested = options
.endpoint
@@ -8271,12 +8278,6 @@ impl ChatApp {
entry.base_url = Some(normalized_endpoint_local.clone());
base_overridden_local = true;
}
if options.force_cloud_base_url {
entry.extra.insert(
OLLAMA_MODE_KEY.to_string(),
Value::String("cloud".to_string()),
);
}
(existing, normalized_endpoint_local, base_overridden_local)
} else {
return Err(anyhow!("Provider '{}' is not configured", options.provider));
@@ -10666,7 +10667,7 @@ struct CloudSetupOptions {
impl CloudSetupOptions {
fn parse(args: &[&str]) -> Result<Self> {
let mut options = CloudSetupOptions {
provider: "ollama".to_string(),
provider: "ollama_cloud".to_string(),
endpoint: None,
api_key: None,
force_cloud_base_url: false,
@@ -10712,7 +10713,7 @@ impl CloudSetupOptions {
}
if options.provider.trim().is_empty() {
options.provider = "ollama".to_string();
options.provider = "ollama_cloud".to_string();
}
options.provider = canonical_provider_name(&options.provider);
@@ -10722,10 +10723,11 @@ impl CloudSetupOptions {
}
fn canonical_provider_name(provider: &str) -> String {
let normalized = provider.trim().replace('_', "-").to_ascii_lowercase();
let normalized = provider.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"" => "ollama".to_string(),
"ollama-cloud" => "ollama".to_string(),
"" => "ollama_cloud".to_string(),
"ollama" => "ollama_cloud".to_string(),
"ollama_cloud" => "ollama_cloud".to_string(),
value => value.to_string(),
}
}