diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index cf1105e..f07b5dc 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -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 diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 04ae92c..27a8379 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -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 diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 7e3fa6b..e00350d 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index c993fab..63c8893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_config.py b/tests/test_config.py index 6c8ef86..f23ec77 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_llm.py b/tests/test_llm.py index 872af2f..f83b59d 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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" +