feat(onboarding): add Ollama provider support with connection validation

This commit is contained in:
X-iZhang
2026-02-24 19:45:02 +00:00
parent 5705720d0e
commit ea00578cee
6 changed files with 261 additions and 37 deletions
+159 -37
View File
@@ -307,6 +307,35 @@ def validate_tavily_key(api_key: str) -> tuple[bool, str]:
return False, f"Error: {e}"
def validate_ollama_connection(base_url: str) -> tuple[bool, str, list[str]]:
"""Validate that Ollama is reachable at the given base URL.
Args:
base_url: The Ollama server base URL.
Returns:
Tuple of (is_valid, message, model_names).
model_names is a list of pulled model names (empty if unreachable).
"""
if not base_url:
return True, "Skipped (no URL provided)", []
try:
import httpx
resp = httpx.get(f"{base_url.rstrip('/')}/api/tags", timeout=5)
if resp.status_code == 200:
data = resp.json()
models = data.get("models", [])
names = [m.get("name", "?") for m in models]
if names:
preview = ", ".join(names[:5])
return True, f"Connected — {len(names)} model(s): {preview}", names
return True, "Connected (no models pulled yet)", []
return False, f"HTTP {resp.status_code}", []
except Exception as e:
return False, f"Cannot reach Ollama: {e}", []
# =============================================================================
# Display Helpers
# =============================================================================
@@ -396,11 +425,12 @@ def _step_provider(config: EvoScientistConfig) -> str:
Choice(title="NVIDIA (DeepSeek, Kimi, GLM, MiniMax, Step, etc.)", value="nvidia"),
Choice(title="SiliconFlow (third party)", value="siliconflow"),
Choice(title="OpenRouter (third party)", value="openrouter"),
Choice(title="Ollama (local models)", value="ollama"),
Choice(title="Other (OpenAI-compatible)", value="custom"),
]
# Set default based on current config
default = config.provider if config.provider in ["anthropic", "openai", "google-genai", "nvidia", "siliconflow", "openrouter", "custom"] else "anthropic"
default = config.provider if config.provider in ["anthropic", "openai", "google-genai", "nvidia", "siliconflow", "openrouter", "ollama", "custom"] else "anthropic"
provider = questionary.select(
"Select your LLM provider:",
@@ -426,6 +456,7 @@ def _provider_key_info(config: EvoScientistConfig, provider: str):
"siliconflow": ("SiliconFlow", config.siliconflow_api_key or os.environ.get("SILICONFLOW_API_KEY", ""), validate_siliconflow_key),
"openrouter": ("OpenRouter", config.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY", ""), validate_openrouter_key),
"custom": ("Custom", config.custom_api_key or os.environ.get("CUSTOM_API_KEY", ""), None),
"ollama": ("Ollama", "__no_key__", None),
}
return mapping.get(provider, ("OpenAI", config.openai_api_key or os.environ.get("OPENAI_API_KEY", ""), validate_openai_key))
@@ -551,16 +582,102 @@ def _step_base_url(config: EvoScientistConfig) -> str:
return url.strip()
def _step_model(config: EvoScientistConfig, provider: str) -> str:
def _step_ollama_base_url(config: EvoScientistConfig) -> tuple[str, list[str]]:
"""Prompt for Ollama server base URL and validate connection.
Args:
config: Current configuration.
Returns:
Tuple of (base_url, detected_model_names).
"""
current = config.ollama_base_url or os.environ.get("OLLAMA_BASE_URL", "")
default = current if current else "http://localhost:11434"
url = questionary.text(
f"Ollama base URL (Enter for {default}):",
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if url is None:
raise KeyboardInterrupt()
url = url.strip()
detected_models: list[str] = []
if url:
console.print(" [dim]Checking Ollama connection...[/dim]", end="")
valid, msg, detected_models = validate_ollama_connection(url)
if valid:
console.print(f"\r [green]\u2713 {msg}[/green] ")
else:
console.print(f"\r [yellow]\u2717 {msg}[/yellow] ")
console.print(" [dim]You can start Ollama later and it will work.[/dim]")
return url, detected_models
def _step_model(
config: EvoScientistConfig,
provider: str,
*,
ollama_detected_models: list[str] | None = None,
) -> str:
"""Step 3: Select model for the provider.
Args:
config: Current configuration.
provider: Selected provider name.
ollama_detected_models: Model names detected from a live Ollama server.
Returns:
Selected model name.
"""
# Ollama: show only what's actually pulled on the server
if provider == "ollama":
if ollama_detected_models:
_CUSTOM_SENTINEL = "__custom__"
choices = [
Choice(title=name, value=name)
for name in ollama_detected_models
]
choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL))
default = ollama_detected_models[0]
if config.model in ollama_detected_models:
default = config.model
selected = questionary.select(
"Select model:",
choices=choices,
default=default,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
).ask()
if selected is None:
raise KeyboardInterrupt()
if selected != _CUSTOM_SENTINEL:
return selected
# No detected models (server down or empty) — direct text input
if not ollama_detected_models:
console.print(" [dim]No models detected — type the model name you plan to pull.[/dim]")
model = questionary.text(
"Model name:",
style=WIZARD_STYLE,
qmark=QMARK,
placeholder=FormattedText([("fg:#858585", " e.g. qwen3-coder-next")]),
).ask()
if model is None:
raise KeyboardInterrupt()
model = model.strip()
if not model:
model = "qwen3-coder-next"
console.print(f" [dim]Using default: {model}[/dim]")
return model
# Third-party providers: select from examples or type custom model name
if provider in _THIRD_PARTY_EXAMPLES:
examples = _THIRD_PARTY_EXAMPLES[provider]
@@ -1698,48 +1815,53 @@ def run_onboard(skip_validation: bool = False) -> bool:
provider = _step_provider(config)
config.provider = provider
# Step 2a: Base URL (custom provider only)
# Step 2a: Base URL (custom or ollama provider)
ollama_detected_models: list[str] = []
if provider == "custom":
base_url = _step_base_url(config)
config.custom_base_url = base_url
elif provider == "ollama":
ollama_url, ollama_detected_models = _step_ollama_base_url(config)
config.ollama_base_url = ollama_url
# Step 2b: Provider API Key
new_key = _step_provider_api_key(config, provider, skip_validation)
if new_key is not None:
if provider == "anthropic":
config.anthropic_api_key = new_key
elif provider == "nvidia":
config.nvidia_api_key = new_key
elif provider == "google-genai":
config.google_api_key = new_key
elif provider == "siliconflow":
config.siliconflow_api_key = new_key
elif provider == "openrouter":
config.openrouter_api_key = new_key
elif provider == "custom":
config.custom_api_key = new_key
# Step 2b: Provider API Key (skip for Ollama — no key needed)
if provider != "ollama":
new_key = _step_provider_api_key(config, provider, skip_validation)
if new_key is not None:
if provider == "anthropic":
config.anthropic_api_key = new_key
elif provider == "nvidia":
config.nvidia_api_key = new_key
elif provider == "google-genai":
config.google_api_key = new_key
elif provider == "siliconflow":
config.siliconflow_api_key = new_key
elif provider == "openrouter":
config.openrouter_api_key = new_key
elif provider == "custom":
config.custom_api_key = new_key
else:
config.openai_api_key = new_key
else:
config.openai_api_key = new_key
else:
if provider == "anthropic":
current = config.anthropic_api_key
elif provider == "nvidia":
current = config.nvidia_api_key
elif provider == "google-genai":
current = config.google_api_key
elif provider == "siliconflow":
current = config.siliconflow_api_key
elif provider == "openrouter":
current = config.openrouter_api_key
elif provider == "custom":
current = config.custom_api_key
else:
current = config.openai_api_key
if not current:
_print_step_skipped("API Key", "not set")
if provider == "anthropic":
current = config.anthropic_api_key
elif provider == "nvidia":
current = config.nvidia_api_key
elif provider == "google-genai":
current = config.google_api_key
elif provider == "siliconflow":
current = config.siliconflow_api_key
elif provider == "openrouter":
current = config.openrouter_api_key
elif provider == "custom":
current = config.custom_api_key
else:
current = config.openai_api_key
if not current:
_print_step_skipped("API Key", "not set")
# Step 3: Model
model = _step_model(config, provider)
model = _step_model(config, provider, ollama_detected_models=ollama_detected_models)
config.model = model
# Step 4: Tavily Key
+4
View File
@@ -67,6 +67,7 @@ class EvoScientistConfig:
openrouter_api_key: str = ""
custom_api_key: str = ""
custom_base_url: str = ""
ollama_base_url: str = ""
tavily_api_key: str = ""
# LLM Settings
@@ -329,6 +330,7 @@ _ENV_MAPPINGS = {
"openrouter_api_key": "OPENROUTER_API_KEY",
"custom_api_key": "CUSTOM_API_KEY",
"custom_base_url": "CUSTOM_BASE_URL",
"ollama_base_url": "OLLAMA_BASE_URL",
"tavily_api_key": "TAVILY_API_KEY",
"default_mode": "EVOSCIENTIST_DEFAULT_MODE",
"default_workdir": "EVOSCIENTIST_WORKSPACE_DIR",
@@ -399,5 +401,7 @@ def apply_config_to_env(config: EvoScientistConfig) -> None:
os.environ["CUSTOM_API_KEY"] = config.custom_api_key
if config.custom_base_url and not os.environ.get("CUSTOM_BASE_URL"):
os.environ["CUSTOM_BASE_URL"] = config.custom_base_url
if config.ollama_base_url and not os.environ.get("OLLAMA_BASE_URL"):
os.environ["OLLAMA_BASE_URL"] = config.ollama_base_url
if config.tavily_api_key and not os.environ.get("TAVILY_API_KEY"):
os.environ["TAVILY_API_KEY"] = config.tavily_api_key
+7
View File
@@ -122,6 +122,9 @@ def get_chat_model(
provider = "openai"
elif model_id.startswith("gemini"):
provider = "google-genai"
elif model_id.startswith("ollama:"):
provider = "ollama"
model_id = model_id.removeprefix("ollama:")
elif "/" in model_id:
provider = "nvidia"
else:
@@ -149,6 +152,10 @@ def get_chat_model(
if api_key:
kwargs["api_key"] = api_key
provider = "openai"
elif provider == "ollama":
base_url = os.environ.get("OLLAMA_BASE_URL", "")
if base_url:
kwargs["base_url"] = base_url
# Auto-enable thinking for Anthropic models
if provider == "anthropic" and "thinking" not in kwargs:
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"langchain-openai>=0.3",
"langchain-nvidia-ai-endpoints>=0.3",
"langchain-google-genai>=4.2",
"langchain-ollama>=1.0",
"tavily-python>=0.7",
"pyyaml>=6.0",
"rich>=14.0",
+17
View File
@@ -79,6 +79,7 @@ class TestEvoScientistConfig:
assert config.max_iterations == 3
assert config.show_thinking is True
assert config.ui_backend == "rich"
assert config.ollama_base_url == ""
assert config.imessage_enabled is False
assert config.imessage_allowed_senders == ""
@@ -378,3 +379,19 @@ class TestApplyConfigToEnv:
assert os.environ.get("ANTHROPIC_API_KEY") is None
assert os.environ.get("OPENAI_API_KEY") is None
def test_ollama_base_url_applied(self, clean_env, monkeypatch):
"""Test that ollama_base_url is applied to OLLAMA_BASE_URL env var."""
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
config = EvoScientistConfig(ollama_base_url="http://localhost:11434")
apply_config_to_env(config)
assert os.environ.get("OLLAMA_BASE_URL") == "http://localhost:11434"
def test_ollama_base_url_not_overridden(self, monkeypatch):
"""Test that existing OLLAMA_BASE_URL env var is not overridden."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://existing:11434")
config = EvoScientistConfig(ollama_base_url="http://new:11434")
apply_config_to_env(config)
assert os.environ.get("OLLAMA_BASE_URL") == "http://existing:11434"
+73
View File
@@ -231,3 +231,76 @@ class TestGetChatModel:
call_kwargs = mock_init.call_args[1]
assert call_kwargs["model_provider"] == "anthropic"
# =============================================================================
# Test Ollama provider
# =============================================================================
class TestOllamaProvider:
"""Ollama models are not in the static registry (detected dynamically).
All tests use explicit provider or ollama: prefix."""
@patch("EvoScientist.llm.models.init_chat_model")
def test_explicit_provider(self, mock_init):
"""Test that explicit provider='ollama' routes correctly."""
mock_init.return_value = "mock_model"
get_chat_model("llama3.1:8b", provider="ollama")
call_kwargs = mock_init.call_args[1]
assert call_kwargs["model"] == "llama3.1:8b"
assert call_kwargs["model_provider"] == "ollama"
@patch("EvoScientist.llm.models.init_chat_model")
def test_ollama_base_url_passthrough(self, mock_init, monkeypatch):
"""Test that OLLAMA_BASE_URL env var is passed to kwargs."""
mock_init.return_value = "mock_model"
monkeypatch.setenv("OLLAMA_BASE_URL", "http://gpu-cluster:11434")
get_chat_model("llama3.1:8b", provider="ollama")
call_kwargs = mock_init.call_args[1]
assert call_kwargs["base_url"] == "http://gpu-cluster:11434"
assert call_kwargs["model_provider"] == "ollama"
@patch("EvoScientist.llm.models.init_chat_model")
def test_ollama_no_base_url_when_unset(self, mock_init, monkeypatch):
"""Test that base_url is not set when OLLAMA_BASE_URL is empty."""
mock_init.return_value = "mock_model"
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
get_chat_model("llama3.1:8b", provider="ollama")
call_kwargs = mock_init.call_args[1]
assert "base_url" not in call_kwargs
@patch("EvoScientist.llm.models.init_chat_model")
def test_no_thinking_for_ollama(self, mock_init, monkeypatch):
"""Test that thinking/reasoning is not auto-enabled for Ollama models."""
mock_init.return_value = "mock_model"
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
get_chat_model("llama3.1:8b", provider="ollama")
call_kwargs = mock_init.call_args[1]
assert "thinking" not in call_kwargs
assert "reasoning" not in call_kwargs
def test_no_static_registry_entries(self):
"""Test that Ollama has no static registry entries (models detected dynamically)."""
ollama_models = get_models_for_provider("ollama")
assert len(ollama_models) == 0
@patch("EvoScientist.llm.models.init_chat_model")
def test_ollama_prefix_inference(self, mock_init, monkeypatch):
"""Test that ollama: prefix infers ollama provider."""
mock_init.return_value = "mock_model"
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
get_chat_model("ollama:phi3:mini")
call_kwargs = mock_init.call_args[1]
assert call_kwargs["model"] == "phi3:mini"
assert call_kwargs["model_provider"] == "ollama"