use super::{is_model_pulled, is_reachable, pull_model};
use crate::config::{CredentialsStore, SynthesisConfig, SynthesisMode, SynthesisProvider};
use anyhow::{bail, Result};
use std::time::{Duration, Instant};
const POLL_INTERVAL: Duration = Duration::from_millis(300);
const DAEMON_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) fn needs_ollama_for_resolve(cfg: &SynthesisConfig, creds: &CredentialsStore) -> bool {
match cfg.mode {
SynthesisMode::Ollama => true,
SynthesisMode::Remote => {
matches!(
cfg.provider,
SynthesisProvider::Ollama | SynthesisProvider::Custom
)
}
SynthesisMode::Auto | SynthesisMode::Embedded => {
if matches!(
cfg.provider,
SynthesisProvider::Ollama | SynthesisProvider::Embedded | SynthesisProvider::Custom
) {
return true;
}
let needs_key = SynthesisConfig::provider_needs_credentials(cfg.provider);
!needs_key || creds.api_key_for(cfg.provider).is_none()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaemonState {
AlreadyRunning,
Spawned,
}
#[derive(Debug, Clone)]
pub struct EnsureReport {
pub daemon: DaemonState,
pub model: String,
pub model_was_pulled: bool,
pub skipped: bool,
}
impl EnsureReport {
pub fn skipped() -> Self {
Self {
daemon: DaemonState::AlreadyRunning,
model: String::new(),
model_was_pulled: false,
skipped: true,
}
}
pub fn display(&self) -> String {
if self.skipped {
return "Ollama not required for current config.".to_string();
}
let daemon_msg = match self.daemon {
DaemonState::AlreadyRunning => "Ollama daemon: already running",
DaemonState::Spawned => "Ollama daemon: spawned and ready",
};
let model_msg = if self.model_was_pulled {
format!("Model '{}': pulled", self.model)
} else {
format!("Model '{}': already present", self.model)
};
format!("{daemon_msg}\n{model_msg}")
}
}
pub fn ensure_ready(cfg: &SynthesisConfig) -> Result<EnsureReport> {
let creds = CredentialsStore::load().unwrap_or_default();
ensure_ready_with_creds(cfg, &creds)
}
pub fn ensure_ready_with_creds(
cfg: &SynthesisConfig,
creds: &CredentialsStore,
) -> Result<EnsureReport> {
if !needs_ollama_for_resolve(cfg, creds) {
return Ok(EnsureReport::skipped());
}
let daemon = ensure_daemon(cfg)?;
let model = super::normalize_model_alias(&cfg.model);
let already_pulled = is_model_pulled(cfg).unwrap_or(false);
let model_was_pulled = if !already_pulled {
tracing::info!("Pulling Ollama model '{model}'…");
pull_model(cfg, &model)?;
true
} else {
false
};
Ok(EnsureReport {
daemon,
model,
model_was_pulled,
skipped: false,
})
}
pub fn ensure_daemon(cfg: &SynthesisConfig) -> Result<DaemonState> {
if is_reachable(cfg) {
return Ok(DaemonState::AlreadyRunning);
}
if std::env::var("AGENT_TRACE_NO_OLLAMA_START").as_deref() == Ok("1") {
bail!(
"Ollama daemon is not reachable at {} and AGENT_TRACE_NO_OLLAMA_START=1 is set. \
Start Ollama manually or unset AGENT_TRACE_NO_OLLAMA_START.",
cfg.effective_base_url()
);
}
let bin = std::env::var("OLLAMA_BIN").unwrap_or_else(|_| "ollama".to_string());
tracing::info!("Starting Ollama daemon via '{bin} serve'…");
let _child = std::process::Command::new(&bin)
.arg("serve")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"Ollama binary not found: '{bin}'. Install Ollama from https://ollama.com \
or set OLLAMA_BIN to the path of your Ollama binary."
)
} else {
anyhow::anyhow!("Failed to start Ollama: {e}")
}
})?;
let deadline = Instant::now() + DAEMON_TIMEOUT;
loop {
std::thread::sleep(POLL_INTERVAL);
if is_reachable(cfg) {
tracing::info!("Ollama daemon is now reachable.");
return Ok(DaemonState::Spawned);
}
if Instant::now() >= deadline {
bail!(
"Ollama daemon did not become reachable within {}s. \
Check that Ollama is installed and can bind to {}.",
DAEMON_TIMEOUT.as_secs(),
cfg.effective_base_url()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SynthesisConfig;
#[test]
fn model_alias_normalization_in_ensure_ready_path() {
let alias = super::super::normalize_model_alias("1.5b");
assert_eq!(alias, "qwen2.5:1.5b");
}
#[test]
fn ensure_report_skipped() {
let r = EnsureReport::skipped();
assert!(r.skipped);
assert!(r.display().contains("not required"));
}
#[test]
fn ensure_report_display_already_running() {
let r = EnsureReport {
daemon: DaemonState::AlreadyRunning,
model: "qwen2.5:1.5b".into(),
model_was_pulled: false,
skipped: false,
};
let d = r.display();
assert!(d.contains("already running"));
assert!(d.contains("already present"));
}
#[test]
fn ensure_report_display_spawned_and_pulled() {
let r = EnsureReport {
daemon: DaemonState::Spawned,
model: "qwen2.5:1.5b".into(),
model_was_pulled: true,
skipped: false,
};
let d = r.display();
assert!(d.contains("spawned"));
assert!(d.contains("pulled"));
}
#[test]
fn missing_binary_errors_clearly() {
let cfg = SynthesisConfig::default();
std::env::set_var("AGENT_TRACE_NO_OLLAMA_START", "1");
let result = ensure_daemon(&cfg);
let _ = result; std::env::remove_var("AGENT_TRACE_NO_OLLAMA_START");
}
#[test]
fn native_base_from_custom_url() {
let mut cfg = SynthesisConfig::default();
cfg.base_url = Some("http://myhost:8080/v1".into());
let base = super::super::native_base(&cfg);
assert_eq!(base, "http://myhost:8080");
}
#[test]
fn needs_ollama_for_ollama_provider() {
let cfg = SynthesisConfig::default(); let creds = CredentialsStore::default();
assert!(needs_ollama_for_resolve(&cfg, &creds));
}
#[test]
fn skipped_when_remote_only() {
let mut cfg = SynthesisConfig::default();
cfg.provider = SynthesisProvider::Openai;
cfg.mode = SynthesisMode::Remote;
let mut creds = CredentialsStore::default();
creds.openai = Some(crate::config::ProviderCredentials {
api_key: Some("sk-test".into()),
});
assert!(!needs_ollama_for_resolve(&cfg, &creds));
}
#[test]
fn needs_ollama_when_auto_without_remote_creds() {
let mut cfg = SynthesisConfig::default();
cfg.provider = SynthesisProvider::Openai;
cfg.mode = SynthesisMode::Auto;
let creds = CredentialsStore::default();
assert!(needs_ollama_for_resolve(&cfg, &creds));
}
}