feat(config): align defaults with provider sections

AC:\n- Config defaults include provider TTL/context extras and normalize cloud quotas/endpoints when missing.\n- owlen config init scaffolds the latest schema; config doctor updates legacy env names and issues warnings.\n- Documentation covers init/doctor usage and runtime env precedence.\n\nTests:\n- cargo test -p owlen-cli\n- cargo test -p owlen-core default_config_sets_provider_extras\n- cargo test -p owlen-core ensure_defaults_backfills_missing_provider_metadata
This commit is contained in:
2025-10-24 13:51:15 +02:00
parent e813736b47
commit 25628d1d58
5 changed files with 551 additions and 10 deletions

View File

@@ -36,6 +36,10 @@ pub const LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_
pub const DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA: u64 = 50_000;
/// Default weekly soft quota for Ollama Cloud usage visualization (tokens).
pub const DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA: u64 = 250_000;
/// Default TTL (seconds) for cached model listings per provider.
pub const DEFAULT_PROVIDER_LIST_TTL_SECS: u64 = 60;
/// Default context window (tokens) assumed when provider metadata is absent.
pub const DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS: u32 = 8_192;
/// Default base URL for local Ollama daemons.
pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434";
/// Default OpenAI API base URL.
@@ -392,6 +396,13 @@ impl Config {
self.providers.entry(name).or_insert(default_cfg);
}
if let Some(local) = self.providers.get_mut("ollama_local") {
normalize_local_provider_config(local);
}
if let Some(cloud) = self.providers.get_mut("ollama_cloud") {
normalize_cloud_provider_config(cloud);
}
if self.schema_version.is_empty() {
self.schema_version = Self::default_schema_version();
}
@@ -868,13 +879,25 @@ fn default_provider_configs() -> HashMap<String, ProviderConfig> {
}
fn default_ollama_local_config() -> ProviderConfig {
let mut extra = HashMap::new();
extra.insert(
"list_ttl_secs".to_string(),
serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)),
);
extra.insert(
"default_context_window".to_string(),
serde_json::Value::Number(serde_json::Number::from(u64::from(
DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS,
))),
);
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(),
extra,
}
}
@@ -892,6 +915,16 @@ fn default_ollama_cloud_config() -> ProviderConfig {
"weekly_quota_tokens".to_string(),
serde_json::Value::Number(serde_json::Number::from(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA)),
);
extra.insert(
"list_ttl_secs".to_string(),
serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)),
);
extra.insert(
"default_context_window".to_string(),
serde_json::Value::Number(serde_json::Number::from(u64::from(
DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS,
))),
);
ProviderConfig {
enabled: false,
@@ -935,6 +968,119 @@ fn default_provider_config_for(name: &str) -> Option<ProviderConfig> {
}
}
fn ensure_numeric_extra(
extra: &mut HashMap<String, serde_json::Value>,
key: &str,
default_value: u64,
) {
let needs_update = match extra.get(key) {
Some(existing) => existing.as_u64().is_none(),
None => true,
};
if needs_update {
extra.insert(
key.to_string(),
serde_json::Value::Number(serde_json::Number::from(default_value)),
);
}
}
fn ensure_string_extra(
extra: &mut HashMap<String, serde_json::Value>,
key: &str,
default_value: &str,
) {
let needs_update = match extra.get(key) {
Some(existing) => existing
.as_str()
.map(|value| value.trim().is_empty())
.unwrap_or(true),
None => true,
};
if needs_update {
extra.insert(
key.to_string(),
serde_json::Value::String(default_value.to_string()),
);
}
}
fn normalize_local_provider_config(provider: &mut ProviderConfig) {
if provider.provider_type.trim().is_empty() || provider.provider_type != "ollama" {
provider.provider_type = "ollama".to_string();
}
ensure_numeric_extra(
&mut provider.extra,
"list_ttl_secs",
DEFAULT_PROVIDER_LIST_TTL_SECS,
);
ensure_numeric_extra(
&mut provider.extra,
"default_context_window",
u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS),
);
}
fn normalize_cloud_provider_config(provider: &mut ProviderConfig) {
if provider.provider_type.trim().is_empty()
|| !provider.provider_type.eq_ignore_ascii_case("ollama_cloud")
{
provider.provider_type = "ollama_cloud".to_string();
}
match provider
.base_url
.as_ref()
.map(|value| value.trim_end_matches('/'))
{
None => {
provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => {
provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
_ => {}
}
if provider
.api_key_env
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
provider.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
ensure_string_extra(
&mut provider.extra,
OLLAMA_CLOUD_ENDPOINT_KEY,
OLLAMA_CLOUD_BASE_URL,
);
ensure_numeric_extra(
&mut provider.extra,
"hourly_quota_tokens",
DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA,
);
ensure_numeric_extra(
&mut provider.extra,
"weekly_quota_tokens",
DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA,
);
ensure_numeric_extra(
&mut provider.extra,
"list_ttl_secs",
DEFAULT_PROVIDER_LIST_TTL_SECS,
);
ensure_numeric_extra(
&mut provider.extra,
"default_context_window",
u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS),
);
}
fn normalize_provider_key(name: &str) -> String {
let normalized = name.trim().to_ascii_lowercase();
match normalized.as_str() {
@@ -2012,6 +2158,162 @@ mod tests {
}
}
#[test]
fn default_config_sets_provider_extras() {
let config = Config::default();
let local = config
.providers
.get("ollama_local")
.expect("local provider");
assert_eq!(
local
.extra
.get("list_ttl_secs")
.and_then(|value| value.as_u64()),
Some(DEFAULT_PROVIDER_LIST_TTL_SECS)
);
assert_eq!(
local
.extra
.get("default_context_window")
.and_then(|value| value.as_u64()),
Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS))
);
let cloud = config
.providers
.get("ollama_cloud")
.expect("cloud provider");
assert_eq!(
cloud
.extra
.get("list_ttl_secs")
.and_then(|value| value.as_u64()),
Some(DEFAULT_PROVIDER_LIST_TTL_SECS)
);
assert_eq!(
cloud
.extra
.get("default_context_window")
.and_then(|value| value.as_u64()),
Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS))
);
assert_eq!(
cloud
.extra
.get("hourly_quota_tokens")
.and_then(|value| value.as_u64()),
Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA)
);
assert_eq!(
cloud
.extra
.get("weekly_quota_tokens")
.and_then(|value| value.as_u64()),
Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA)
);
assert_eq!(
cloud
.extra
.get(OLLAMA_CLOUD_ENDPOINT_KEY)
.and_then(|value| value.as_str()),
Some(OLLAMA_CLOUD_BASE_URL)
);
}
#[test]
fn ensure_defaults_backfills_missing_provider_metadata() {
let mut config = Config::default();
if let Some(local) = config.providers.get_mut("ollama_local") {
local.extra.remove("list_ttl_secs");
local.extra.insert(
"default_context_window".into(),
serde_json::Value::String("".into()),
);
local.provider_type.clear();
}
if let Some(cloud) = config.providers.get_mut("ollama_cloud") {
cloud.extra.remove("list_ttl_secs");
cloud.extra.insert(
"default_context_window".into(),
serde_json::Value::String("invalid".into()),
);
cloud.extra.remove("hourly_quota_tokens");
cloud.extra.remove("weekly_quota_tokens");
cloud.extra.remove(OLLAMA_CLOUD_ENDPOINT_KEY);
cloud.api_key_env = None;
cloud.provider_type.clear();
cloud.base_url = Some(LEGACY_OLLAMA_CLOUD_BASE_URL.to_string());
}
config.ensure_defaults();
let local = config
.providers
.get("ollama_local")
.expect("local provider");
assert_eq!(local.provider_type, "ollama");
assert_eq!(
local
.extra
.get("list_ttl_secs")
.and_then(|value| value.as_u64()),
Some(DEFAULT_PROVIDER_LIST_TTL_SECS)
);
assert_eq!(
local
.extra
.get("default_context_window")
.and_then(|value| value.as_u64()),
Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS))
);
let cloud = config
.providers
.get("ollama_cloud")
.expect("cloud provider");
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_API_KEY_ENV));
assert_eq!(
cloud
.extra
.get("list_ttl_secs")
.and_then(|value| value.as_u64()),
Some(DEFAULT_PROVIDER_LIST_TTL_SECS)
);
assert_eq!(
cloud
.extra
.get("default_context_window")
.and_then(|value| value.as_u64()),
Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS))
);
assert_eq!(
cloud
.extra
.get("hourly_quota_tokens")
.and_then(|value| value.as_u64()),
Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA)
);
assert_eq!(
cloud
.extra
.get("weekly_quota_tokens")
.and_then(|value| value.as_u64()),
Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA)
);
assert_eq!(
cloud
.extra
.get(OLLAMA_CLOUD_ENDPOINT_KEY)
.and_then(|value| value.as_str()),
Some(OLLAMA_CLOUD_BASE_URL)
);
}
#[test]
fn test_storage_platform_specific_paths() {
let config = Config::default();
@@ -2110,7 +2412,7 @@ mod tests {
.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.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL));
assert_eq!(cloud.api_key.as_deref(), Some("secret"));
}