fix(config): align ollama cloud defaults with upstream

This commit is contained in:
2025-10-23 19:25:58 +02:00
parent 38a4c55eaa
commit 3e8788dd44
12 changed files with 219 additions and 59 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_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL,
self as core_config, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV,
LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
};
use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID};
@@ -272,8 +273,15 @@ 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());
let should_update_env = match entry.api_key_env.as_deref() {
None => true,
Some(value) => {
value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
}
};
if should_update_env {
entry.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
if force

View File

@@ -23,15 +23,15 @@ pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
/// Extra config key storing the preferred Ollama Cloud endpoint.
pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint";
/// Canonical Ollama Cloud base URL.
pub const OLLAMA_CLOUD_BASE_URL: &str = "https://api.ollama.com";
pub const OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com";
/// Legacy Ollama Cloud base URL (accepted for backward compatibility).
pub const LEGACY_OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com";
pub const LEGACY_OLLAMA_CLOUD_BASE_URL: &str = "https://api.ollama.com";
/// Preferred environment variable used for Ollama Cloud authentication.
pub const OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_KEY";
pub const OLLAMA_API_KEY_ENV: &str = "OLLAMA_API_KEY";
/// Legacy environment variable accepted for backward compatibility.
pub const LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLEN_OLLAMA_CLOUD_API_KEY";
/// Legacy environment variable still accepted for Ollama Cloud authentication.
pub const OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
pub const LEGACY_OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
/// Legacy environment variable used by earlier Owlen releases.
pub const LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_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.
@@ -645,6 +645,49 @@ impl Config {
}
self.providers = migrated;
// If the legacy local provider was configured with the hosted base URL, promote the
// settings to the cloud provider for backward compatibility.
let enable_cloud_from_local = self
.providers
.get("ollama_local")
.and_then(|cfg| cfg.base_url.as_ref())
.map(|base| is_cloud_base_url(Some(base)))
.unwrap_or(false);
if enable_cloud_from_local {
let mut local_api_key = None;
let mut local_api_key_env = None;
if let Some(local) = self.providers.get_mut("ollama_local") {
local_api_key = local.api_key.take();
local_api_key_env = local.api_key_env.take();
local.enabled = false;
}
if let Some(cloud) = self.providers.get_mut("ollama_cloud") {
if cloud.api_key.is_none() {
cloud.api_key = local_api_key;
}
if cloud.api_key_env.is_none() {
cloud.api_key_env = local_api_key_env;
}
if cloud.base_url.is_none() {
cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
let update_api_key_env = match cloud.api_key_env.as_deref() {
None => true,
Some(value) => {
value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
}
};
if update_api_key_env {
cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
cloud.enabled = true;
}
}
}
fn merge_legacy_ollama_provider(
@@ -687,13 +730,15 @@ impl Config {
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
.as_deref()
.is_some_and(|value| value == LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV)
{
cloud.api_key_env = Some(OWLEN_OLLAMA_CLOUD_API_KEY_ENV.to_string());
let update_api_key_env = match cloud.api_key_env.as_deref() {
None => true,
Some(value) => {
value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
}
};
if update_api_key_env {
cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
}
}
@@ -841,7 +886,7 @@ fn default_ollama_cloud_config() -> ProviderConfig {
provider_type: canonical_provider_type("ollama_cloud"),
base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(OWLEN_OLLAMA_CLOUD_API_KEY_ENV.to_string()),
api_key_env: Some(OLLAMA_API_KEY_ENV.to_string()),
extra,
}
}
@@ -2013,7 +2058,7 @@ mod tests {
.get("ollama_cloud")
.expect("default cloud provider");
assert!(!cloud.enabled);
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV));
}
#[test]
@@ -2023,7 +2068,7 @@ mod tests {
let cloud = ensure_provider_config(&mut config, "ollama-cloud");
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_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV));
assert!(config.providers.contains_key("ollama_cloud"));
assert!(!config.providers.contains_key("ollama-cloud"));
}
@@ -2072,7 +2117,7 @@ mod tests {
.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));
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV));
}
#[test]

View File

@@ -1,11 +1,12 @@
//! Ollama provider built on top of the `ollama-rs` crate.
use std::{
collections::{HashMap, HashSet},
convert::TryFrom,
env,
net::{SocketAddr, TcpStream},
pin::Pin,
process::Command,
sync::Arc,
sync::{Arc, OnceLock},
time::{Duration, Instant, SystemTime},
};
@@ -29,7 +30,7 @@ use serde_json::{Map as JsonMap, Value, json};
use tokio::{sync::RwLock, time::sleep};
#[cfg(test)]
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::sync::{Mutex, MutexGuard};
#[cfg(test)]
use tokio_test::block_on;
use uuid::Uuid;
@@ -37,8 +38,8 @@ use uuid::Uuid;
use crate::{
Error, Result,
config::{
GeneralSettings, LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, OWLEN_OLLAMA_CLOUD_API_KEY_ENV,
GeneralSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV,
OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
},
llm::{LlmProvider, ProviderConfig},
mcp::McpToolDescriptor,
@@ -57,6 +58,17 @@ const LOCAL_TAGS_TIMEOUT_STEPS_MS: [u64; 3] = [400, 800, 1_600];
const LOCAL_TAGS_RETRY_DELAYS_MS: [u64; 2] = [150, 300];
const HEALTHCHECK_TIMEOUT_MS: u64 = 1_000;
static LEGACY_CLOUD_ENV_WARNING: OnceLock<()> = OnceLock::new();
fn warn_legacy_cloud_env(var_name: &str) {
if LEGACY_CLOUD_ENV_WARNING.set(()).is_ok() {
warn!(
"Using legacy Ollama Cloud API key environment variable `{var_name}`. \
Prefer configuring OLLAMA_API_KEY; legacy names remain supported but may be removed."
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum OllamaMode {
Local,
@@ -390,10 +402,15 @@ impl OllamaProvider {
let mut api_key = resolve_api_key(config.api_key.clone())
.or_else(|| resolve_api_key_env_hint(config.api_key_env.as_deref()))
.or_else(|| env_var_non_empty(OWLEN_OLLAMA_CLOUD_API_KEY_ENV))
.or_else(|| env_var_non_empty(LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV))
.or_else(|| env_var_non_empty("OLLAMA_API_KEY"))
.or_else(|| env_var_non_empty("OLLAMA_CLOUD_API_KEY"));
.or_else(|| env_var_non_empty(OLLAMA_API_KEY_ENV))
.or_else(|| {
warn_legacy_cloud_env(LEGACY_OLLAMA_CLOUD_API_KEY_ENV);
env_var_non_empty(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
})
.or_else(|| {
warn_legacy_cloud_env(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV);
env_var_non_empty(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
});
let api_key_present = api_key.is_some();
let configured_mode = configured_mode_from_extra(config);
@@ -429,7 +446,7 @@ impl OllamaProvider {
if matches!(variant, ProviderVariant::Cloud) {
if !api_key_present {
return Err(Error::Config(
"Ollama Cloud API key not configured. Set providers.ollama_cloud.api_key or OLLAMA_CLOUD_API_KEY."
"Ollama Cloud API key not configured. Set providers.ollama_cloud.api_key or export OLLAMA_API_KEY (legacy: OLLAMA_CLOUD_API_KEY / OWLEN_OLLAMA_CLOUD_API_KEY)."
.into(),
));
}
@@ -1247,12 +1264,20 @@ impl OllamaProvider {
let description = build_model_description(scope_tag, detail.as_ref());
let context_window = detail.as_ref().and_then(|info| {
pick_first_u64(
&info.model_info,
&["context_length", "num_ctx", "max_context"],
)
.and_then(|raw| u32::try_from(raw).ok())
});
ModelInfo {
id: name.clone(),
name,
description: Some(description),
provider: self.provider_name.clone(),
context_window: None,
context_window,
capabilities,
supports_tools: false,
}
@@ -1771,10 +1796,18 @@ fn env_var_non_empty(name: &str) -> Option<String> {
}
fn resolve_api_key_env_hint(env_var: Option<&str>) -> Option<String> {
env_var
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(env_var_non_empty)
let var = env_var?.trim();
if var.is_empty() {
return None;
}
if var.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| var.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
{
warn_legacy_cloud_env(var);
}
env_var_non_empty(var)
}
fn resolve_api_key(configured: Option<String>) -> Option<String> {
@@ -1838,6 +1871,15 @@ fn normalize_base_url(
return Err("Ollama base URLs must not include additional path segments".to_string());
}
if mode_hint == OllamaMode::Cloud {
if let Some(host) = url.host_str() {
if host.eq_ignore_ascii_case("api.ollama.com") {
url.set_host(Some("ollama.com"))
.map_err(|err| format!("Failed to normalise Ollama Cloud host: {err}"))?;
}
}
}
url.set_query(None);
url.set_fragment(None);
@@ -1900,6 +1942,7 @@ fn build_client_for_base(
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Map as JsonMap, Value};
use std::collections::HashMap;
#[test]
@@ -1930,6 +1973,12 @@ mod tests {
assert_eq!(url, "https://ollama.com");
}
#[test]
fn normalize_base_url_canonicalises_api_hostname() {
let url = normalize_base_url(Some("https://api.ollama.com"), OllamaMode::Cloud).unwrap();
assert_eq!(url, "https://ollama.com");
}
#[test]
fn normalize_base_url_rejects_cloud_without_https() {
let err = normalize_base_url(Some("http://ollama.com"), OllamaMode::Cloud).unwrap_err();
@@ -2088,6 +2137,34 @@ mod tests {
assert_eq!(models[0].name, "llama3");
}
#[test]
fn convert_model_propagates_context_window_from_details() {
let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed");
let local = LocalModel {
name: "gemma3n:e4b".into(),
modified_at: "2024-01-01T00:00:00Z".into(),
size: 0,
};
let mut meta = JsonMap::new();
meta.insert(
"context_length".into(),
Value::Number(serde_json::Number::from(32_768)),
);
let detail = OllamaModelInfo {
license: String::new(),
modelfile: String::new(),
parameters: String::new(),
template: String::new(),
model_info: meta,
capabilities: vec![],
};
let info = provider.convert_model(OllamaMode::Local, local, Some(detail));
assert_eq!(info.context_window, Some(32_768));
}
#[test]
fn fetch_scope_tags_with_retry_retries_on_timeout_then_succeeds() {
let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed");

View File

@@ -18,3 +18,4 @@ serde_json = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
reqwest = { package = "reqwest", version = "0.11", features = ["json", "stream"] }
log = { workspace = true }

View File

@@ -1,6 +1,7 @@
use std::{env, time::Duration};
use async_trait::async_trait;
use log::warn;
use owlen_core::{
Error as CoreError, Result as CoreResult,
config::OLLAMA_CLOUD_BASE_URL,
@@ -13,7 +14,9 @@ use serde_json::{Number, Value};
use super::OllamaClient;
const API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
const API_KEY_ENV: &str = "OLLAMA_API_KEY";
const LEGACY_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
const LEGACY_OWLEN_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_KEY";
/// ModelProvider implementation for the hosted Ollama Cloud service.
pub struct OllamaCloudProvider {
@@ -22,7 +25,7 @@ pub struct OllamaCloudProvider {
impl OllamaCloudProvider {
/// Construct a new cloud provider. An API key must be supplied either
/// directly or via the `OLLAMA_CLOUD_API_KEY` environment variable.
/// directly or via the `OLLAMA_API_KEY` environment variable.
pub fn new(
base_url: Option<String>,
api_key: Option<String>,
@@ -95,14 +98,34 @@ fn resolve_api_key(api_key: Option<String>) -> CoreResult<(String, &'static str)
let key_from_env = env::var(API_KEY_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
.filter(|value| !value.is_empty())
.map(|value| (value, API_KEY_ENV))
.or_else(|| {
env::var(LEGACY_API_KEY_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| (value, LEGACY_API_KEY_ENV))
})
.or_else(|| {
env::var(LEGACY_OWLEN_API_KEY_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| (value, LEGACY_OWLEN_API_KEY_ENV))
});
if let Some(key) = key_from_env {
if let Some((key, source_env)) = key_from_env {
if source_env != API_KEY_ENV {
warn!(
"Using legacy Ollama Cloud API key environment variable `{source_env}`. Prefer OLLAMA_API_KEY."
);
}
return Ok((key, "env"));
}
Err(CoreError::Config(
"Ollama Cloud API key not configured. Set OLLAMA_CLOUD_API_KEY or configure an API key."
"Ollama Cloud API key not configured. Set OLLAMA_API_KEY (legacy: OLLAMA_CLOUD_API_KEY / OWLEN_OLLAMA_CLOUD_API_KEY) or configure an API key."
.into(),
))
}

View File

@@ -63,7 +63,8 @@ use crate::ui::format_tool_output;
use crate::widgets::model_picker::FilterMode;
use crate::{commands, highlight};
use owlen_core::config::{
OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_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
@@ -9745,8 +9746,15 @@ impl ChatApp {
let existing = entry.api_key.clone();
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 should_update_env = match entry.api_key_env.as_deref() {
None => true,
Some(value) => {
value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
}
};
if should_update_env {
entry.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
let requested = options
.endpoint