use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use super::chat_wire::{apply_chat_wire_sources, ProviderChatWireSources};
use super::*;
static CONFIG: OnceLock<Arc<ProvidersConfig>> = OnceLock::new();
static CONFIG_PATHS: OnceLock<Vec<PathBuf>> = OnceLock::new();
static CONFIG_CHAT_WIRE_SOURCES: OnceLock<ProviderChatWireSources> = OnceLock::new();
static RUNTIME_CATALOG_OVERLAY: OnceLock<RwLock<Option<ProvidersConfig>>> = OnceLock::new();
pub const HOST_PROVIDERS_CONFIG_ENV: &str = "HARN_HOST_PROVIDERS_CONFIG";
thread_local! {
static LLM_CONFIG_OVERRIDES_CONTEXT: RefCell<Option<ProvidersConfig>> = const { RefCell::new(None) };
static LLM_RUNTIME_PROVIDER_ENDPOINTS_CONTEXT: RefCell<RuntimeProviderEndpointOverrides> = RefCell::new(RuntimeProviderEndpointOverrides::default());
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeProviderEndpointOverrides {
endpoints: BTreeMap<String, String>,
}
impl RuntimeProviderEndpointOverrides {
pub fn single(
provider: impl Into<String>,
base_url: impl Into<String>,
) -> Result<Self, String> {
let mut overrides = Self::default();
overrides.insert(provider, base_url)?;
Ok(overrides)
}
pub fn insert(
&mut self,
provider: impl Into<String>,
base_url: impl Into<String>,
) -> Result<(), String> {
let provider = provider.into().trim().to_string();
if provider.is_empty() {
return Err("runtime provider endpoint requires a provider name".to_string());
}
let base_url = base_url.into().trim().to_string();
if base_url.is_empty() {
return Err(format!(
"runtime provider endpoint for `{provider}` must not be empty"
));
}
let parsed = url::Url::parse(&base_url).map_err(|error| {
format!("runtime provider endpoint for `{provider}` is not a URL: {error}")
})?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err(format!(
"runtime provider endpoint for `{provider}` must be an absolute HTTP(S) URL"
));
}
self.endpoints.insert(provider, base_url);
Ok(())
}
pub fn is_empty(&self) -> bool {
self.endpoints.is_empty()
}
fn endpoint_for(&self, provider: &str) -> Option<&str> {
self.endpoints.get(provider).map(String::as_str)
}
}
pub fn load_config() -> &'static ProvidersConfig {
load_config_snapshot().as_ref()
}
fn load_config_snapshot() -> &'static Arc<ProvidersConfig> {
CONFIG.get_or_init(|| {
let verbose_config_logging = matches!(
std::env::var("HARN_VERBOSE_CONFIG").ok().as_deref(),
Some("1" | "true" | "TRUE" | "yes" | "YES")
) || matches!(
std::env::var("HARN_ACP_VERBOSE").ok().as_deref(),
Some("1" | "true" | "TRUE" | "yes" | "YES")
);
let host_path = std::env::var(HOST_PROVIDERS_CONFIG_ENV).ok();
let user_path = std::env::var("HARN_PROVIDERS_CONFIG").ok();
let home_path =
user_providers_config_path().map(|path| path.to_string_lossy().into_owned());
let (config, paths, sources) = load_external_config_layers_with_sources(
default_config(),
host_path.as_deref(),
user_path.as_deref(),
home_path.as_deref(),
verbose_config_logging,
);
let _ = CONFIG_PATHS.set(paths);
let _ = CONFIG_CHAT_WIRE_SOURCES.set(sources);
Arc::new(config)
})
}
#[cfg(test)]
pub(super) fn load_external_config_layers(
config: ProvidersConfig,
host_path: Option<&str>,
user_path: Option<&str>,
home_path: Option<&str>,
verbose: bool,
) -> (ProvidersConfig, Vec<PathBuf>) {
let (config, paths, _) =
load_external_config_layers_with_sources(config, host_path, user_path, home_path, verbose);
(config, paths)
}
fn load_external_config_layers_with_sources(
mut config: ProvidersConfig,
host_path: Option<&str>,
user_path: Option<&str>,
home_path: Option<&str>,
verbose: bool,
) -> (ProvidersConfig, Vec<PathBuf>, ProviderChatWireSources) {
let mut loaded_paths = Vec::new();
let mut sources = ProviderChatWireSources::new();
apply_chat_wire_sources(&mut sources, &config, ProviderConfigLayer::Embedded);
if let Some(path) = host_path {
merge_external_config(
&mut config,
path,
verbose,
&mut loaded_paths,
&mut sources,
ProviderConfigLayer::HostConfig { path: path.into() },
);
}
let user_loaded = user_path.is_some_and(|path| {
merge_external_config(
&mut config,
path,
verbose,
&mut loaded_paths,
&mut sources,
ProviderConfigLayer::UserConfig { path: path.into() },
)
});
if !user_loaded {
if let Some(path) = home_path {
merge_external_config(
&mut config,
path,
false,
&mut loaded_paths,
&mut sources,
ProviderConfigLayer::UserConfig { path: path.into() },
);
}
}
(config, loaded_paths, sources)
}
fn merge_external_config(
config: &mut ProvidersConfig,
path: &str,
verbose: bool,
loaded_paths: &mut Vec<PathBuf>,
sources: &mut ProviderChatWireSources,
layer: ProviderConfigLayer,
) -> bool {
let Some(overlay) = read_external_config(path, verbose) else {
return false;
};
config.merge_from(&overlay);
apply_chat_wire_sources(sources, &overlay, layer);
loaded_paths.push(PathBuf::from(path));
true
}
fn read_external_config(path: &str, verbose: bool) -> Option<ProvidersConfig> {
match std::fs::read_to_string(path) {
Ok(content) => match parse_config_toml_with_diagnostics(&content) {
Ok(parsed) => {
for diagnostic in &parsed.diagnostics {
eprintln!("[llm_config] warning in {path}: {diagnostic}");
}
if verbose {
eprintln!(
"[llm_config] Loaded {} providers, {} aliases from {}",
parsed.config.providers.len(),
parsed.config.aliases.len(),
path
);
}
Some(parsed.config)
}
Err(error) => {
eprintln!("[llm_config] TOML parse error in {path}: {error}");
None
}
},
Err(error) => {
if verbose {
eprintln!("[llm_config] Cannot read {path}: {error}");
}
None
}
}
}
pub fn user_providers_config_path() -> Option<std::path::PathBuf> {
crate::user_dirs::config_file("providers.toml")
}
pub fn loaded_config_path() -> Option<std::path::PathBuf> {
loaded_config_paths().last().cloned()
}
pub fn loaded_config_paths() -> &'static [PathBuf] {
let _ = load_config();
CONFIG_PATHS.get().map(Vec::as_slice).unwrap_or_default()
}
pub fn provider_chat_wire_contract(provider: &str) -> Option<ProviderChatWireContract> {
let _ = load_config();
LLM_CONFIG_OVERRIDES_CONTEXT.with(|cell| {
let user = cell.borrow();
let runtime = runtime_catalog_overlay()
.read()
.expect("runtime catalog overlay poisoned");
let config =
effective_config_snapshot(load_config_snapshot(), runtime.as_ref(), user.as_ref());
let definition = config.providers.get(provider)?;
let mut sources = CONFIG_CHAT_WIRE_SOURCES
.get()
.and_then(|sources| sources.get(provider))
.cloned()
.unwrap_or_default();
for (overlay, layer) in [
(runtime.as_ref(), ProviderConfigLayer::RuntimeCatalog),
(user.as_ref(), ProviderConfigLayer::ExecutionOverride),
] {
if let Some(definition) = overlay.and_then(|config| config.providers.get(provider)) {
sources.apply(definition, &layer);
}
}
Some(sources.contract(definition))
})
}
pub fn set_user_overrides(config: Option<ProvidersConfig>) {
LLM_CONFIG_OVERRIDES_CONTEXT.with(|cell| *cell.borrow_mut() = config);
}
pub(crate) fn swap_user_overrides(next: Option<ProvidersConfig>) -> Option<ProvidersConfig> {
LLM_CONFIG_OVERRIDES_CONTEXT.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), next))
}
pub(crate) fn swap_runtime_provider_endpoint_overrides(
next: RuntimeProviderEndpointOverrides,
) -> RuntimeProviderEndpointOverrides {
LLM_RUNTIME_PROVIDER_ENDPOINTS_CONTEXT
.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), next))
}
pub fn set_runtime_provider_endpoint_overrides(overrides: RuntimeProviderEndpointOverrides) {
let _ = swap_runtime_provider_endpoint_overrides(overrides);
}
pub fn clear_runtime_provider_endpoint_overrides() {
set_runtime_provider_endpoint_overrides(RuntimeProviderEndpointOverrides::default());
}
pub(crate) fn runtime_provider_endpoint(provider: &str) -> Option<String> {
LLM_RUNTIME_PROVIDER_ENDPOINTS_CONTEXT
.with(|cell| cell.borrow().endpoint_for(provider).map(str::to_string))
}
pub fn clear_user_overrides() {
set_user_overrides(None);
}
pub fn set_runtime_catalog_overlay(config: Option<ProvidersConfig>) {
*runtime_catalog_overlay()
.write()
.expect("runtime catalog overlay poisoned") = config;
}
pub fn clear_runtime_catalog_overlay() {
set_runtime_catalog_overlay(None);
}
pub(crate) fn effective_config() -> Arc<ProvidersConfig> {
LLM_CONFIG_OVERRIDES_CONTEXT.with(|cell| {
let user_overrides = cell.borrow();
let runtime_overlay = runtime_catalog_overlay()
.read()
.expect("runtime catalog overlay poisoned");
effective_config_snapshot(
load_config_snapshot(),
runtime_overlay.as_ref(),
user_overrides.as_ref(),
)
})
}
fn effective_config_snapshot(
base: &Arc<ProvidersConfig>,
runtime_overlay: Option<&ProvidersConfig>,
user_overrides: Option<&ProvidersConfig>,
) -> Arc<ProvidersConfig> {
if runtime_overlay.is_none() && user_overrides.is_none() {
Arc::clone(base)
} else {
Arc::new(merge_config_overlays(base, runtime_overlay, user_overrides))
}
}
fn merge_config_overlays(
base: &ProvidersConfig,
runtime_overlay: Option<&ProvidersConfig>,
user_overrides: Option<&ProvidersConfig>,
) -> ProvidersConfig {
let mut merged = base.clone();
if let Some(overlay) = runtime_overlay {
merged.merge_from(overlay);
}
if let Some(overlay) = user_overrides {
merged.merge_from(overlay);
}
merged
}
pub(crate) fn effective_config_with_user_overrides(
user_overrides: Option<&ProvidersConfig>,
) -> ProvidersConfig {
let runtime_overlay = runtime_catalog_overlay()
.read()
.expect("runtime catalog overlay poisoned");
merge_config_overlays(load_config(), runtime_overlay.as_ref(), user_overrides)
}
fn runtime_catalog_overlay() -> &'static RwLock<Option<ProvidersConfig>> {
RUNTIME_CATALOG_OVERLAY.get_or_init(|| RwLock::new(None))
}
pub(crate) fn default_config() -> ProvidersConfig {
harn_provider_catalog::config::packaged_config().clone()
}
#[cfg(test)]
pub(crate) fn merge_global_config(overlay: ProvidersConfig) -> ProvidersConfig {
let mut config = default_config();
config.merge_from(&overlay);
config
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn endpoint_only_user_layer_resets_host_adapter_and_its_source() {
let directory = tempfile::tempdir().unwrap();
let host = directory.path().join("host.toml");
let home = directory.path().join("home.toml");
std::fs::write(&host, "[providers.ollama]\nchat_endpoint = '/v1/chat/completions'\nchat_api_adapter = 'ollama_openai_compat'\n").unwrap();
std::fs::write(&home, "[providers.ollama]\nchat_endpoint = '/api/chat'\n").unwrap();
let (config, paths, sources) = load_external_config_layers_with_sources(
default_config(),
host.to_str(),
None,
home.to_str(),
false,
);
assert_eq!(paths, vec![host, home.clone()]);
let contract = sources["ollama"]
.clone()
.contract(&config.providers["ollama"]);
assert_eq!(contract.chat_endpoint, "/api/chat");
assert_eq!(contract.chat_api_adapter, ChatApiAdapter::ModelDefault);
let source = Some(ProviderConfigLayer::UserConfig {
path: home.to_string_lossy().into_owned(),
});
assert_eq!(contract.sources.chat_endpoint, source);
assert_eq!(contract.sources.chat_api_adapter, source);
std::fs::write(
&home,
"[providers.ollama]\nbase_url = 'http://localhost:11435'\n",
)
.unwrap();
let (config, _, sources) = load_external_config_layers_with_sources(
default_config(),
paths[0].to_str(),
None,
home.to_str(),
false,
);
assert_eq!(
config.providers["ollama"].chat_api_adapter,
Some(ChatApiAdapter::OllamaOpenAiCompat)
);
assert!(matches!(
sources["ollama"].chat_api_adapter,
Some(ProviderConfigLayer::HostConfig { .. })
));
}
fn config(default_provider: &str, qc_defaults: &[(&str, &str)]) -> ProvidersConfig {
ProvidersConfig {
default_provider: Some(default_provider.to_string()),
qc_defaults: qc_defaults
.iter()
.map(|(provider, model)| ((*provider).to_string(), (*model).to_string()))
.collect(),
..ProvidersConfig::default()
}
}
#[test]
fn effective_snapshot_reuses_base_without_overlays() {
let base = Arc::new(config("base", &[("base", "base/model")]));
let first = effective_config_snapshot(&base, None, None);
let second = effective_config_snapshot(&base, None, None);
assert!(Arc::ptr_eq(&base, &first));
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn effective_snapshot_isolates_and_exactly_merges_overlays() {
let base = Arc::new(config(
"base",
&[("base", "base/model"), ("shared", "base/shared")],
));
let runtime = config(
"runtime",
&[("runtime", "runtime/model"), ("shared", "runtime/shared")],
);
let user = config("user", &[("user", "user/model"), ("shared", "user/shared")]);
let merged = effective_config_snapshot(&base, Some(&runtime), Some(&user));
assert!(!Arc::ptr_eq(&base, &merged));
assert_eq!(
merged.as_ref(),
&config(
"user",
&[
("base", "base/model"),
("runtime", "runtime/model"),
("shared", "user/shared"),
("user", "user/model"),
],
)
);
assert_eq!(
base.as_ref(),
&config("base", &[("base", "base/model"), ("shared", "base/shared")])
);
}
}