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

@@ -6,17 +6,24 @@ mod bootstrap;
mod commands;
mod mcp;
use anyhow::Result;
use anyhow::{Result, anyhow};
use clap::{Parser, Subcommand};
use commands::{
cloud::{CloudCommand, run_cloud_command},
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
};
use mcp::{McpCommand, run_mcp_command};
use owlen_core::config as core_config;
use owlen_core::config::McpMode;
use owlen_core::config::{
self as core_config, Config, DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA,
DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS,
DEFAULT_PROVIDER_LIST_TTL_SECS, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OLLAMA_CLOUD_BASE_URL,
LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpMode, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_ENDPOINT_KEY,
};
use owlen_core::mode::Mode;
use owlen_tui::config;
use serde_json::{Number as JsonNumber, Value as JsonValue};
use std::env;
/// Owlen - Terminal UI for LLM chat
#[derive(Parser, Debug)]
@@ -56,6 +63,12 @@ enum ConfigCommand {
Doctor,
/// Print the resolved configuration file path
Path,
/// Create a fresh configuration file using the latest defaults
Init {
/// Overwrite the existing configuration if present.
#[arg(long)]
force: bool,
},
}
async fn run_command(command: OwlenCommand) -> Result<()> {
@@ -85,15 +98,35 @@ fn run_config_command(command: ConfigCommand) -> Result<()> {
println!("{}", path.display());
Ok(())
}
ConfigCommand::Init { force } => run_config_init(force),
}
}
fn run_config_init(force: bool) -> Result<()> {
let config_path = core_config::default_config_path();
if config_path.exists() && !force {
return Err(anyhow!(
"Configuration already exists at {}. Re-run with --force to overwrite.",
config_path.display()
));
}
let mut config = Config::default();
let _ = config.refresh_mcp_servers(None);
config.validate()?;
config::save_config(&config)?;
println!("Wrote default configuration to {}.", config_path.display());
Ok(())
}
fn run_config_doctor() -> Result<()> {
let config_path = core_config::default_config_path();
let existed = config_path.exists();
let mut config = config::try_load_config().unwrap_or_default();
let _ = config.refresh_mcp_servers(None);
let mut changes = Vec::new();
let mut warnings = Vec::new();
if !existed {
changes.push("created configuration file from defaults".to_string());
@@ -111,13 +144,164 @@ fn run_config_doctor() -> Result<()> {
}
}
if let Some(entry) = config.providers.get_mut("ollama_local") {
if entry.provider_type.trim().is_empty() || entry.provider_type != "ollama" {
entry.provider_type = "ollama".to_string();
if let Some(local) = config.providers.get_mut("ollama_local") {
if ensure_numeric_extra_with_change(
&mut local.extra,
"list_ttl_secs",
DEFAULT_PROVIDER_LIST_TTL_SECS,
) {
changes.push("added providers.ollama_local.list_ttl_secs (default 60)".to_string());
}
if ensure_numeric_extra_with_change(
&mut local.extra,
"default_context_window",
u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS),
) {
changes.push(format!(
"added providers.ollama_local.default_context_window (default {})",
DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS
));
}
if local.provider_type.trim().is_empty() || local.provider_type != "ollama" {
local.provider_type = "ollama".to_string();
changes.push("normalised providers.ollama_local.provider_type to 'ollama'".to_string());
}
}
if let Some(cloud) = config.providers.get_mut("ollama_cloud") {
if cloud.provider_type.trim().is_empty()
|| !cloud.provider_type.eq_ignore_ascii_case("ollama_cloud")
{
cloud.provider_type = "ollama_cloud".to_string();
changes.push(
"normalised providers.ollama_cloud.provider_type to 'ollama_cloud'".to_string(),
);
}
let previous_base_url = cloud.base_url.clone();
match cloud
.base_url
.as_ref()
.map(|value| value.trim_end_matches('/'))
{
None => {
cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => {
cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
_ => {}
}
if cloud.base_url != previous_base_url {
changes.push(
"normalised providers.ollama_cloud.base_url to https://ollama.com".to_string(),
);
}
let original_api_key_env = cloud.api_key_env.clone();
let needs_env_update = cloud
.api_key_env
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true);
if needs_env_update {
cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
if let Some(ref value) = original_api_key_env {
if value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
{
cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
}
if cloud.api_key_env != original_api_key_env {
changes
.push("updated providers.ollama_cloud.api_key_env to 'OLLAMA_API_KEY'".to_string());
}
if ensure_string_extra_with_change(
&mut cloud.extra,
OLLAMA_CLOUD_ENDPOINT_KEY,
OLLAMA_CLOUD_BASE_URL,
) {
changes.push(
"added providers.ollama_cloud.extra.cloud_endpoint (default https://ollama.com)"
.to_string(),
);
}
if ensure_numeric_extra_with_change(
&mut cloud.extra,
"hourly_quota_tokens",
DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA,
) {
changes.push(format!(
"added providers.ollama_cloud.hourly_quota_tokens (default {})",
DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA
));
}
if ensure_numeric_extra_with_change(
&mut cloud.extra,
"weekly_quota_tokens",
DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA,
) {
changes.push(format!(
"added providers.ollama_cloud.weekly_quota_tokens (default {})",
DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA
));
}
if ensure_numeric_extra_with_change(
&mut cloud.extra,
"list_ttl_secs",
DEFAULT_PROVIDER_LIST_TTL_SECS,
) {
changes.push("added providers.ollama_cloud.list_ttl_secs (default 60)".to_string());
}
if ensure_numeric_extra_with_change(
&mut cloud.extra,
"default_context_window",
u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS),
) {
changes.push(format!(
"added providers.ollama_cloud.default_context_window (default {})",
DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS
));
}
}
let canonical_env = env::var(OLLAMA_API_KEY_ENV)
.ok()
.filter(|value| !value.trim().is_empty());
let legacy_env = env::var(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
.ok()
.filter(|value| !value.trim().is_empty());
let legacy_alt_env = env::var(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
.ok()
.filter(|value| !value.trim().is_empty());
if canonical_env.is_some() {
if legacy_env.is_some() {
warnings.push(format!(
"Both {OLLAMA_API_KEY_ENV} and {LEGACY_OLLAMA_CLOUD_API_KEY_ENV} are set; Owlen will prefer {OLLAMA_API_KEY_ENV}."
));
}
if legacy_alt_env.is_some() {
warnings.push(format!(
"Both {OLLAMA_API_KEY_ENV} and {LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV} are set; Owlen will prefer {OLLAMA_API_KEY_ENV}."
));
}
} else {
if legacy_env.is_some() {
warnings.push(format!(
"Legacy environment variable {LEGACY_OLLAMA_CLOUD_API_KEY_ENV} is set. Rename it to {OLLAMA_API_KEY_ENV} to match the latest configuration schema."
));
}
if legacy_alt_env.is_some() {
warnings.push(format!(
"Legacy environment variable {LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV} is set. Rename it to {OLLAMA_API_KEY_ENV} to match the latest configuration schema."
));
}
}
let mut ensure_default_enabled = true;
if !config.providers.values().any(|cfg| cfg.enabled) {
@@ -213,9 +397,63 @@ fn run_config_doctor() -> Result<()> {
}
}
if !warnings.is_empty() {
println!("Warnings:");
for warning in warnings {
println!(" - {warning}");
}
}
Ok(())
}
fn ensure_numeric_extra_with_change(
extra: &mut std::collections::HashMap<String, JsonValue>,
key: &str,
default_value: u64,
) -> bool {
match extra.get_mut(key) {
Some(existing) => {
if existing.as_u64().is_some() {
false
} else {
*existing = JsonValue::Number(JsonNumber::from(default_value));
true
}
}
None => {
extra.insert(
key.to_string(),
JsonValue::Number(JsonNumber::from(default_value)),
);
true
}
}
}
fn ensure_string_extra_with_change(
extra: &mut std::collections::HashMap<String, JsonValue>,
key: &str,
default_value: &str,
) -> bool {
match extra.get_mut(key) {
Some(existing) => match existing.as_str() {
Some(value) if !value.trim().is_empty() => false,
_ => {
*existing = JsonValue::String(default_value.to_string());
true
}
},
None => {
extra.insert(
key.to_string(),
JsonValue::String(default_value.to_string()),
);
true
}
}
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
// Parse command-line arguments