use clawft_types::config::{
api_base_to_service_url, service_url_to_api_base, Config, DEFAULT_LOCAL_LLM_API_BASE,
DEFAULT_LOCAL_LLM_MODEL, DEFAULT_LOCAL_LLM_MODEL_ROUTED, DEFAULT_LOCAL_LLM_SERVICE_URL,
LLM_MODEL_ENV, LLM_SERVICE_URL_ENV,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalLlmResolution {
pub service_url: String,
pub api_base: String,
pub model: String,
pub routed_model: String,
pub url_source: &'static str,
pub model_source: &'static str,
pub agents_model_source: &'static str,
}
pub fn resolve_local_llm(config: &Config, agents_model_source: &'static str) -> LocalLlmResolution {
let cfg_url = config
.kernel
.llm
.as_ref()
.and_then(|c| c.service_url.clone())
.filter(|s| !s.is_empty());
let cfg_model = config
.kernel
.llm
.as_ref()
.and_then(|c| c.model.clone())
.filter(|s| !s.is_empty());
let env_url = std::env::var(LLM_SERVICE_URL_ENV)
.ok()
.filter(|s| !s.is_empty());
let env_model = std::env::var(LLM_MODEL_ENV)
.ok()
.filter(|s| !s.is_empty());
let url_from_env = env_url.is_some();
let url_from_cfg = cfg_url.is_some();
let model_from_env = env_model.is_some();
let model_from_cfg = cfg_model.is_some();
let service_url = env_url
.or(cfg_url)
.unwrap_or_else(|| DEFAULT_LOCAL_LLM_SERVICE_URL.to_string());
let service_url = api_base_to_service_url(&service_url);
let api_base = service_url_to_api_base(&service_url);
let model = env_model
.or(cfg_model)
.unwrap_or_else(|| DEFAULT_LOCAL_LLM_MODEL.to_string());
let model = model
.strip_prefix("local/")
.unwrap_or(model.as_str())
.to_string();
let routed_model = if model.contains('/') {
model.clone()
} else {
format!("local/{model}")
};
let url_source = if url_from_env {
"env:LLM_SERVICE_URL"
} else if url_from_cfg {
"config:[kernel.llm].service_url"
} else {
"default:local-adr060"
};
let model_source = if model_from_env {
"env:LLM_MODEL"
} else if model_from_cfg {
"config:[kernel.llm].model"
} else {
"default:local-adr060"
};
let agents_model = config.agents.defaults.model.clone();
let use_agents = !model_from_env
&& !model_from_cfg
&& agents_model != DEFAULT_LOCAL_LLM_MODEL_ROUTED
&& agents_model != DEFAULT_LOCAL_LLM_MODEL
&& !clawft_types::config::is_legacy_cloud_default_model(&agents_model);
let (routed_model, model_source, model) = if use_agents {
let bare = agents_model
.rsplit_once('/')
.map(|(_, m)| m.to_string())
.unwrap_or_else(|| agents_model.clone());
(agents_model, agents_model_source, bare)
} else {
(routed_model, model_source, model)
};
LocalLlmResolution {
service_url,
api_base,
model,
routed_model,
url_source,
model_source,
agents_model_source,
}
}
pub fn apply_local_llm_bridge(
config: &mut Config,
agents_model_source: &'static str,
) -> LocalLlmResolution {
let resolution = resolve_local_llm(config, agents_model_source);
let agents_is_defaultish = config.agents.defaults.model == DEFAULT_LOCAL_LLM_MODEL_ROUTED
|| config.agents.defaults.model == DEFAULT_LOCAL_LLM_MODEL
|| clawft_types::config::is_legacy_cloud_default_model(&config.agents.defaults.model)
|| resolution.model_source.starts_with("env:")
|| resolution.model_source.starts_with("config:[kernel.llm]");
if agents_is_defaultish {
config.agents.defaults.model = resolution.routed_model.clone();
}
let local_base_empty = config
.providers
.local
.api_base
.as_ref()
.map(|s| s.is_empty())
.unwrap_or(true);
if local_base_empty {
let base = if resolution.api_base.is_empty() {
DEFAULT_LOCAL_LLM_API_BASE.to_string()
} else {
resolution.api_base.clone()
};
config.providers.local.api_base = Some(base);
}
resolution
}
pub fn local_placement_allowed(config: &Config, resolution: &LocalLlmResolution) -> bool {
resolution.url_source == "default:local-adr060"
&& config
.providers
.local
.api_base
.as_deref()
.is_none_or(|b| b.is_empty() || b.trim_end_matches('/') == DEFAULT_LOCAL_LLM_API_BASE)
}
pub fn format_resolution_log(r: &LocalLlmResolution) -> String {
format!(
"url={} ({}) model={} ({}) agents.defaults.model layer={}",
r.service_url, r.url_source, r.routed_model, r.model_source, r.agents_model_source
)
}
#[cfg(test)]
mod tests {
use super::*;
use clawft_types::config::{KernelConfig, LlmEndpointConfig};
#[test]
fn placement_applies_only_when_nothing_explicit_chose_the_endpoint() {
let mut config = Config::default();
let r = apply_local_llm_bridge(&mut config, "default");
if r.url_source == "default:local-adr060" {
assert!(local_placement_allowed(&config, &r));
}
config.providers.local.api_base = Some("http://127.0.0.1:9100/v1".into());
let r = resolve_local_llm(&config, "default");
if r.url_source == "default:local-adr060" {
assert!(!local_placement_allowed(&config, &r));
}
let mut config = Config {
kernel: KernelConfig {
llm: Some(LlmEndpointConfig {
service_url: Some("http://127.0.0.1:9000".into()),
model: None,
}),
..KernelConfig::default()
},
..Config::default()
};
let r = apply_local_llm_bridge(&mut config, "default");
assert!(!local_placement_allowed(&config, &r));
let mut r = r;
r.url_source = "env:LLM_SERVICE_URL";
let config = Config::default();
assert!(!local_placement_allowed(&config, &r));
}
#[test]
fn defaults_resolve_to_adr060() {
let config = Config::default();
let r = resolve_local_llm(&config, "default");
assert_eq!(r.service_url, DEFAULT_LOCAL_LLM_SERVICE_URL);
assert_eq!(r.api_base, DEFAULT_LOCAL_LLM_API_BASE);
assert_eq!(r.model, DEFAULT_LOCAL_LLM_MODEL);
assert_eq!(r.routed_model, DEFAULT_LOCAL_LLM_MODEL_ROUTED);
assert_eq!(r.url_source, "default:local-adr060");
}
#[test]
fn kernel_llm_overrides_defaults() {
let config = Config {
kernel: KernelConfig {
llm: Some(LlmEndpointConfig {
service_url: Some("http://127.0.0.1:9000".into()),
model: Some("my-model".into()),
}),
..KernelConfig::default()
},
..Config::default()
};
let r = resolve_local_llm(&config, "default");
assert_eq!(r.service_url, "http://127.0.0.1:9000");
assert_eq!(r.api_base, "http://127.0.0.1:9000/v1");
assert_eq!(r.model, "my-model");
assert_eq!(r.routed_model, "local/my-model");
assert_eq!(r.url_source, "config:[kernel.llm].service_url");
assert_eq!(r.model_source, "config:[kernel.llm].model");
}
#[test]
fn bridge_stamps_agents_and_local_provider() {
let mut config = Config::default();
config.agents.defaults.model = "deepseek/deepseek-chat".into();
let r = apply_local_llm_bridge(&mut config, "workspace:.clawft/config.json");
assert_eq!(config.agents.defaults.model, DEFAULT_LOCAL_LLM_MODEL_ROUTED);
assert_eq!(
config.providers.local.api_base.as_deref(),
Some(DEFAULT_LOCAL_LLM_API_BASE)
);
assert_eq!(r.agents_model_source, "workspace:.clawft/config.json");
}
#[test]
fn bridge_preserves_explicit_non_default_agents_model() {
let mut config = Config::default();
config.agents.defaults.model = "anthropic/claude-sonnet-4-5-20250514".into();
apply_local_llm_bridge(&mut config, "global:weave.toml");
assert_eq!(
config.agents.defaults.model,
"anthropic/claude-sonnet-4-5-20250514"
);
assert_eq!(
config.providers.local.api_base.as_deref(),
Some(DEFAULT_LOCAL_LLM_API_BASE)
);
}
#[test]
fn bridge_respects_existing_local_api_base() {
let mut config = Config::default();
config.providers.local.api_base = Some("http://192.168.1.5:8080/v1".into());
apply_local_llm_bridge(&mut config, "default");
assert_eq!(
config.providers.local.api_base.as_deref(),
Some("http://192.168.1.5:8080/v1")
);
}
#[test]
#[cfg(feature = "native")]
fn service_llm_defaults_match_types_constants() {
assert_eq!(
clawft_service_llm::DEFAULT_LLM_SERVICE_URL,
DEFAULT_LOCAL_LLM_SERVICE_URL
);
assert_eq!(
clawft_service_llm::DEFAULT_LLM_MODEL,
DEFAULT_LOCAL_LLM_MODEL
);
assert_eq!(
clawft_llm::local_provider::HERMES_SERVING_BASE_URL,
DEFAULT_LOCAL_LLM_API_BASE
);
assert_eq!(
clawft_llm::local_provider::HERMES_DEFAULT_MODEL,
DEFAULT_LOCAL_LLM_MODEL
);
}
}