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

@@ -15,7 +15,7 @@
## Release Assumptions ## Release Assumptions
* Provider config has **separate entries** for local (“ollama”) and cloud (“ollama-cloud”), both optional. * Provider config has **separate entries** for local (“ollama”) and cloud (“ollama-cloud”), both optional.
* **Cloud base URL** is config-driven (default `[Inference] https://api.ollama.com`), **local** default `http://localhost:11434`. * **Cloud base URL** is config-driven (default `[Inference] https://ollama.com`), **local** default `http://localhost:11434`.
* Cloud requests include `Authorization: Bearer <API_KEY>`. * Cloud requests include `Authorization: Bearer <API_KEY>`.
* Token counts (`prompt_eval_count`, `eval_count`) are read from provider responses **if available**; else fallback to local estimation. * Token counts (`prompt_eval_count`, `eval_count`) are read from provider responses **if available**; else fallback to local estimation.
* Rate-limit quotas unknown → **user-configurable** (`hourly_quota_tokens`, `weekly_quota_tokens`); we still track actual usage. * Rate-limit quotas unknown → **user-configurable** (`hourly_quota_tokens`, `weekly_quota_tokens`); we still track actual usage.
@@ -42,7 +42,7 @@
* New provider “ollama-cloud”. * New provider “ollama-cloud”.
* Build `reqwest::Client` with default headers incl. `Authorization: Bearer <API_KEY>`. * Build `reqwest::Client` with default headers incl. `Authorization: Bearer <API_KEY>`.
* Base URL from config (default `[Inference] https://api.ollama.com`). * Base URL from config (default `[Inference] https://ollama.com`).
* If no key: provider not registered; cloud models hidden. * If no key: provider not registered; cloud models hidden.
* **AC:** With key → cloud models list & chat work; without key → no cloud provider listed. * **AC:** With key → cloud models list & chat work; without key → no cloud provider listed.
* **Tests:** No key (hidden), invalid key (401 handled), valid key (happy path). * **Tests:** No key (hidden), invalid key (401 handled), valid key (happy path).
@@ -168,8 +168,8 @@
[providers.ollama_cloud] [providers.ollama_cloud]
enabled = true enabled = true
base_url = "https://api.ollama.com" # [Inference] default base_url = "https://ollama.com" # Hosted default
api_key = "" # read from env OWLEN_OLLAMA_CLOUD_API_KEY if empty api_key = "" # read from env OLLAMA_API_KEY if empty
hourly_quota_tokens = 50000 # configurable; visual only hourly_quota_tokens = 50000 # configurable; visual only
weekly_quota_tokens = 250000 # configurable; visual only weekly_quota_tokens = 250000 # configurable; visual only
list_ttl_secs = 60 list_ttl_secs = 60

View File

@@ -1,9 +1,7 @@
## fix(config): align ollama cloud defaults with api host ## fix(config): align ollama cloud defaults with current api host
- **Severity:** High cloud calls 404 because defaults still point at `https://ollama.com` and require `OLLAMA_CLOUD_API_KEY` (`crates/owlen-core/src/config.rs:26`, `config.toml:16`, `README.md:6`). - **Severity:** High older defaults used the non-canonical base host and bespoke env var, forcing manual tweaks before cloud requests succeeded (`crates/owlen-core/src/config.rs`, `config.toml`, `docs/configuration.md`).
- **Issue:** Fresh installs silently fail against Ollama Cloud; env naming diverges from the spec (`OWLEN_OLLAMA_CLOUD_API_KEY` vs vendor-provided `OLLAMA_API_KEY`) so doctor tooling cannot guide users. - **Resolution (2025-10-23):** Canonical base URL now defaults to `https://ollama.com` per the latest Ollama Cloud REST guide, with automatic normalisation of `https://api.ollama.com` as a legacy alias. Configuration templating, CLI/TUI helpers, and generated docs reference the vendor-provided `OLLAMA_API_KEY`, while still accepting `OLLAMA_CLOUD_API_KEY` and `OWLEN_OLLAMA_CLOUD_API_KEY` with a one-time warning.
- **Plan:** Switch canonical host to `https://api.ollama.com`, teach config loaders to honor the new env while warning on the legacy name, and update generated configs, doctor output, and docs. - **Verification:** Added unit coverage for base URL normalisation, adjusted config/env migration tests, and re-ran `cargo test -p owlen-core -p owlen-providers` to confirm env fallbacks continue to work.
- **Acceptance Criteria:** New configs hit cloud without manual overrides; legacy env keeps working with a single warning; `owlen providers status` shows cloud reachable when the key is set.
- **Test Notes:** Extend `Config` env resolution tests, add CLI smoke test for status under both env names, and run `cargo test -p owlen-core -p owlen-cli`.
## fix(provider/ollama): keep stream whitespace intact ## fix(provider/ollama): keep stream whitespace intact
- **Severity:** High `generate_stream` trims every line so assistants lose leading newlines and indentation (`crates/owlen-providers/src/ollama/shared.rs:135` and :161). - **Severity:** High `generate_stream` trims every line so assistants lose leading newlines and indentation (`crates/owlen-providers/src/ollama/shared.rs:135` and :161).
@@ -56,7 +54,7 @@
## feat(config): align generated config with provider sections & env fallbacks ## feat(config): align generated config with provider sections & env fallbacks
- **Severity:** Medium the sample `config.toml` and `README` snippets still use flat `[providers.ollama_cloud]` keys with `api_key_env = "OLLAMA_CLOUD_API_KEY"` and base URL `https://ollama.com` (`config.toml:16`, `README.md:119`), diverging from `crates/owlen-core/src/config.rs`. - **Severity:** Medium the sample `config.toml` and `README` snippets still use flat `[providers.ollama_cloud]` keys with `api_key_env = "OLLAMA_CLOUD_API_KEY"` and base URL `https://ollama.com` (`config.toml:16`, `README.md:119`), diverging from `crates/owlen-core/src/config.rs`.
- **Issue:** Onboarding breaks because the UI and doctor expect the `[providers.ollama_cloud]` schema with `OWLEN_OLLAMA_CLOUD_API_KEY` and `[providers.ollama]` defaults, so fresh installs require manual correction. - **Issue:** Onboarding breaks because the UI and doctor expect the `[providers.ollama_cloud]` schema with `OLLAMA_API_KEY` as the default (while warning on legacy names), so fresh installs require manual correction.
- **Plan:** Regenerate the CLI templated config, update documentation snippets, implement migration warnings for the legacy env name, and ensure doctor explains both env vars with precedence. - **Plan:** Regenerate the CLI templated config, update documentation snippets, implement migration warnings for the legacy env name, and ensure doctor explains both env vars with precedence.
- **Acceptance Criteria:** `owlen config init` writes the new schema, doctor reports actionable guidance when the legacy env is present, and README/config docs match the generated file. - **Acceptance Criteria:** `owlen config init` writes the new schema, doctor reports actionable guidance when the legacy env is present, and README/config docs match the generated file.
- **Test Notes:** Snapshot regeneration output, add config migration tests covering legacy envs, and run `cargo test -p owlen-core config` suite. - **Test Notes:** Snapshot regeneration output, add config migration tests covering legacy envs, and run `cargo test -p owlen-core config` suite.

View File

@@ -14,7 +14,7 @@ base_url = "http://localhost:11434"
enabled = false enabled = false
provider_type = "ollama_cloud" provider_type = "ollama_cloud"
base_url = "https://ollama.com" base_url = "https://ollama.com"
api_key_env = "OLLAMA_CLOUD_API_KEY" api_key_env = "OLLAMA_API_KEY"
[providers.openai] [providers.openai]
enabled = false enabled = false

View File

@@ -7,7 +7,8 @@ use clap::Subcommand;
use owlen_core::LlmProvider; use owlen_core::LlmProvider;
use owlen_core::ProviderConfig; use owlen_core::ProviderConfig;
use owlen_core::config::{ 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, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
}; };
use owlen_core::credentials::{ApiCredentials, CredentialManager, OLLAMA_CLOUD_CREDENTIAL_ID}; 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()), Value::String(normalized.clone()),
); );
if entry.api_key_env.is_none() { let should_update_env = match entry.api_key_env.as_deref() {
entry.api_key_env = Some(OLLAMA_CLOUD_API_KEY_ENV.to_string()); 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 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. /// Extra config key storing the preferred Ollama Cloud endpoint.
pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint"; pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint";
/// Canonical Ollama Cloud base URL. /// 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). /// 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. /// 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. /// Legacy environment variable accepted for backward compatibility.
pub const LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLEN_OLLAMA_CLOUD_API_KEY"; pub const LEGACY_OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
/// Legacy environment variable still accepted for Ollama Cloud authentication. /// Legacy environment variable used by earlier Owlen releases.
pub const OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY"; pub const LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_KEY";
/// Default base URL for local Ollama daemons. /// Default base URL for local Ollama daemons.
pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434"; pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434";
/// Default OpenAI API base URL. /// Default OpenAI API base URL.
@@ -645,6 +645,49 @@ impl Config {
} }
self.providers = migrated; 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( fn merge_legacy_ollama_provider(
@@ -687,13 +730,15 @@ impl Config {
if cloud.base_url.is_none() { if cloud.base_url.is_none() {
cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
} }
if cloud.api_key_env.is_none() let update_api_key_env = match cloud.api_key_env.as_deref() {
|| cloud None => true,
.api_key_env Some(value) => {
.as_deref() value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
.is_some_and(|value| value == LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV) || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
{ }
cloud.api_key_env = Some(OWLEN_OLLAMA_CLOUD_API_KEY_ENV.to_string()); };
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"), provider_type: canonical_provider_type("ollama_cloud"),
base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()), base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()),
api_key: None, 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, extra,
} }
} }
@@ -2013,7 +2058,7 @@ mod tests {
.get("ollama_cloud") .get("ollama_cloud")
.expect("default cloud provider"); .expect("default cloud provider");
assert!(!cloud.enabled); 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] #[test]
@@ -2023,7 +2068,7 @@ mod tests {
let cloud = ensure_provider_config(&mut config, "ollama-cloud"); let cloud = ensure_provider_config(&mut config, "ollama-cloud");
assert_eq!(cloud.provider_type, "ollama_cloud"); assert_eq!(cloud.provider_type, "ollama_cloud");
assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); 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"));
assert!(!config.providers.contains_key("ollama-cloud")); assert!(!config.providers.contains_key("ollama-cloud"));
} }
@@ -2072,7 +2117,7 @@ mod tests {
.expect("cloud provider created"); .expect("cloud provider created");
assert!(cloud.enabled); assert!(cloud.enabled);
assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); 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] #[test]

View File

@@ -1,11 +1,12 @@
//! Ollama provider built on top of the `ollama-rs` crate. //! Ollama provider built on top of the `ollama-rs` crate.
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
convert::TryFrom,
env, env,
net::{SocketAddr, TcpStream}, net::{SocketAddr, TcpStream},
pin::Pin, pin::Pin,
process::Command, process::Command,
sync::Arc, sync::{Arc, OnceLock},
time::{Duration, Instant, SystemTime}, time::{Duration, Instant, SystemTime},
}; };
@@ -29,7 +30,7 @@ use serde_json::{Map as JsonMap, Value, json};
use tokio::{sync::RwLock, time::sleep}; use tokio::{sync::RwLock, time::sleep};
#[cfg(test)] #[cfg(test)]
use std::sync::{Mutex, MutexGuard, OnceLock}; use std::sync::{Mutex, MutexGuard};
#[cfg(test)] #[cfg(test)]
use tokio_test::block_on; use tokio_test::block_on;
use uuid::Uuid; use uuid::Uuid;
@@ -37,8 +38,8 @@ use uuid::Uuid;
use crate::{ use crate::{
Error, Result, Error, Result,
config::{ config::{
GeneralSettings, LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, GeneralSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV,
OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
}, },
llm::{LlmProvider, ProviderConfig}, llm::{LlmProvider, ProviderConfig},
mcp::McpToolDescriptor, 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 LOCAL_TAGS_RETRY_DELAYS_MS: [u64; 2] = [150, 300];
const HEALTHCHECK_TIMEOUT_MS: u64 = 1_000; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum OllamaMode { enum OllamaMode {
Local, Local,
@@ -390,10 +402,15 @@ impl OllamaProvider {
let mut api_key = resolve_api_key(config.api_key.clone()) 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(|| 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(OLLAMA_API_KEY_ENV))
.or_else(|| env_var_non_empty(LEGACY_OLLEN_OLLAMA_CLOUD_API_KEY_ENV)) .or_else(|| {
.or_else(|| env_var_non_empty("OLLAMA_API_KEY")) warn_legacy_cloud_env(LEGACY_OLLAMA_CLOUD_API_KEY_ENV);
.or_else(|| env_var_non_empty("OLLAMA_CLOUD_API_KEY")); 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 api_key_present = api_key.is_some();
let configured_mode = configured_mode_from_extra(config); let configured_mode = configured_mode_from_extra(config);
@@ -429,7 +446,7 @@ impl OllamaProvider {
if matches!(variant, ProviderVariant::Cloud) { if matches!(variant, ProviderVariant::Cloud) {
if !api_key_present { if !api_key_present {
return Err(Error::Config( 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(), .into(),
)); ));
} }
@@ -1247,12 +1264,20 @@ impl OllamaProvider {
let description = build_model_description(scope_tag, detail.as_ref()); 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 { ModelInfo {
id: name.clone(), id: name.clone(),
name, name,
description: Some(description), description: Some(description),
provider: self.provider_name.clone(), provider: self.provider_name.clone(),
context_window: None, context_window,
capabilities, capabilities,
supports_tools: false, 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> { fn resolve_api_key_env_hint(env_var: Option<&str>) -> Option<String> {
env_var let var = env_var?.trim();
.map(str::trim) if var.is_empty() {
.filter(|value| !value.is_empty()) return None;
.and_then(env_var_non_empty) }
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> { 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()); 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_query(None);
url.set_fragment(None); url.set_fragment(None);
@@ -1900,6 +1942,7 @@ fn build_client_for_base(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::{Map as JsonMap, Value};
use std::collections::HashMap; use std::collections::HashMap;
#[test] #[test]
@@ -1930,6 +1973,12 @@ mod tests {
assert_eq!(url, "https://ollama.com"); 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] #[test]
fn normalize_base_url_rejects_cloud_without_https() { fn normalize_base_url_rejects_cloud_without_https() {
let err = normalize_base_url(Some("http://ollama.com"), OllamaMode::Cloud).unwrap_err(); 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"); 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] #[test]
fn fetch_scope_tags_with_retry_retries_on_timeout_then_succeeds() { fn fetch_scope_tags_with_retry_retries_on_timeout_then_succeeds() {
let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed"); let provider = OllamaProvider::new("http://localhost:11434").expect("provider constructed");

View File

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

View File

@@ -1,6 +1,7 @@
use std::{env, time::Duration}; use std::{env, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use log::warn;
use owlen_core::{ use owlen_core::{
Error as CoreError, Result as CoreResult, Error as CoreError, Result as CoreResult,
config::OLLAMA_CLOUD_BASE_URL, config::OLLAMA_CLOUD_BASE_URL,
@@ -13,7 +14,9 @@ use serde_json::{Number, Value};
use super::OllamaClient; 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. /// ModelProvider implementation for the hosted Ollama Cloud service.
pub struct OllamaCloudProvider { pub struct OllamaCloudProvider {
@@ -22,7 +25,7 @@ pub struct OllamaCloudProvider {
impl OllamaCloudProvider { impl OllamaCloudProvider {
/// Construct a new cloud provider. An API key must be supplied either /// 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( pub fn new(
base_url: Option<String>, base_url: Option<String>,
api_key: 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) let key_from_env = env::var(API_KEY_ENV)
.ok() .ok()
.map(|value| value.trim().to_string()) .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")); return Ok((key, "env"));
} }
Err(CoreError::Config( 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(), .into(),
)) ))
} }

View File

@@ -63,7 +63,8 @@ use crate::ui::format_tool_output;
use crate::widgets::model_picker::FilterMode; use crate::widgets::model_picker::FilterMode;
use crate::{commands, highlight}; use crate::{commands, highlight};
use owlen_core::config::{ 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}; use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // 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(); let existing = entry.api_key.clone();
entry.enabled = true; entry.enabled = true;
entry.provider_type = "ollama_cloud".to_string(); entry.provider_type = "ollama_cloud".to_string();
if entry.api_key_env.is_none() { let should_update_env = match entry.api_key_env.as_deref() {
entry.api_key_env = Some(OLLAMA_CLOUD_API_KEY_ENV.to_string()); 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 let requested = options
.endpoint .endpoint

View File

@@ -131,7 +131,7 @@ base_url = "http://localhost:11434"
enabled = false enabled = false
provider_type = "ollama_cloud" provider_type = "ollama_cloud"
base_url = "https://ollama.com" base_url = "https://ollama.com"
api_key_env = "OLLAMA_CLOUD_API_KEY" api_key_env = "OLLAMA_API_KEY"
[providers.openai] [providers.openai]
enabled = false enabled = false
@@ -176,10 +176,10 @@ base_url = "http://localhost:11434"
[providers.ollama_cloud] [providers.ollama_cloud]
enabled = true enabled = true
base_url = "https://ollama.com" base_url = "https://ollama.com"
api_key_env = "OLLAMA_CLOUD_API_KEY" api_key_env = "OLLAMA_API_KEY"
``` ```
Requests target the same `/api/chat` endpoint documented by Ollama and automatically include the API key using a `Bearer` authorization header. If you prefer not to store the key in the config file, either rely on `api_key_env` or export the environment variable manually. Owlen normalises the base URL automatically—it enforces HTTPS, trims trailing slashes, and accepts both `https://ollama.com` and `https://api.ollama.com` without rewriting the host. Requests target the same `/api/chat` endpoint documented by Ollama and automatically include the API key using a `Bearer` authorization header. If you prefer not to store the key in the config file, either rely on `api_key_env` or export the `OLLAMA_API_KEY` environment variable manually (legacy names `OLLAMA_CLOUD_API_KEY` and `OWLEN_OLLAMA_CLOUD_API_KEY` continue to work, but Owlen will emit a warning). Owlen normalises the base URL automatically—it enforces HTTPS, trims trailing slashes, and accepts both `https://ollama.com` and `https://api.ollama.com` without rewriting the host.
> **Tip:** If the official `ollama signin` flow fails on Linux v0.12.3, follow the [Linux Ollama sign-in workaround](#linux-ollama-sign-in-workaround-v0123) in the troubleshooting guide to copy keys from a working machine or register them manually. > **Tip:** If the official `ollama signin` flow fails on Linux v0.12.3, follow the [Linux Ollama sign-in workaround](#linux-ollama-sign-in-workaround-v0123) in the troubleshooting guide to copy keys from a working machine or register them manually.

View File

@@ -94,7 +94,7 @@ base_url = "http://localhost:11434"
enabled = true # set to false if you do not use the hosted API enabled = true # set to false if you do not use the hosted API
provider_type = "ollama_cloud" provider_type = "ollama_cloud"
base_url = "https://ollama.com" base_url = "https://ollama.com"
api_key_env = "OLLAMA_CLOUD_API_KEY" api_key_env = "OLLAMA_API_KEY"
``` ```
#### Step 3: Understanding MCP Server Configuration #### Step 3: Understanding MCP Server Configuration

View File

@@ -56,7 +56,7 @@ If you see `Auth` errors when using the hosted service:
1. Run `owlen cloud setup` to register your API key (with `--api-key` for non-interactive use). 1. Run `owlen cloud setup` to register your API key (with `--api-key` for non-interactive use).
2. Use `owlen cloud status` to verify Owlen can authenticate against [Ollama Cloud](https://docs.ollama.com/cloud). 2. Use `owlen cloud status` to verify Owlen can authenticate against [Ollama Cloud](https://docs.ollama.com/cloud).
3. Ensure `providers.ollama.api_key` is set **or** export `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` when encryption is disabled. With `privacy.encrypt_local_data = true`, the key lives in the encrypted vault and is loaded automatically. 3. Ensure `providers.ollama.api_key` is set **or** export `OLLAMA_API_KEY` (legacy: `OLLAMA_CLOUD_API_KEY` / `OWLEN_OLLAMA_CLOUD_API_KEY`) when encryption is disabled. With `privacy.encrypt_local_data = true`, the key lives in the encrypted vault and is loaded automatically.
4. Confirm the key has access to the requested models. 4. Confirm the key has access to the requested models.
5. Avoid pasting extra quotes or whitespace into the config file—`owlen config doctor` will normalise the entry for you. 5. Avoid pasting extra quotes or whitespace into the config file—`owlen config doctor` will normalise the entry for you.