diff --git a/cli.py b/cli.py index e6f6b1c..7648025 100644 --- a/cli.py +++ b/cli.py @@ -21,8 +21,10 @@ init_settings_file, reset_settings_file, find_settings_file, + resolve_ollama_model, EXAMPLE_SETTINGS_FILENAME, USER_SETTINGS_FILENAME, + ) console = Console() @@ -79,7 +81,7 @@ def create_custom_agent_wizard(settings: RoomsSettings, tracked_env_keys: Option config.custom_function_name = Prompt.ask("Enter the exact function name to call (e.g. process_inference)") else: config.model_type = ModelType.LITELLM - default_model = defaults.litellm_model + default_model = resolve_ollama_model(settings) console.print( "[dim]Hint: For local Ollama use your tag from `ollama list` (e.g. " f"'{default_model}'). For OpenAI use 'gpt-4o'.[/dim]" diff --git a/rooms.settings.example.yaml b/rooms.settings.example.yaml index 413d217..45c7ba9 100644 --- a/rooms.settings.example.yaml +++ b/rooms.settings.example.yaml @@ -2,6 +2,7 @@ # See: https://github.com/ARPAHLS/rooms/issues/26 and #27 defaults: + # Set to "ollama/auto" to automatically pick the first model returned by your local engine litellm_model: "ollama/gemma4:e2b" orchestrator_model: "ollama/gemma4:e2b" temperature: 0.7 @@ -15,6 +16,7 @@ presets: api_key_env: "OPENAI_API_KEY" ollama: + # When litellm_model is set to "ollama/auto", true selects the first active model from `ollama list` auto_select_first: false base_url: "http://localhost:11434" @@ -32,4 +34,4 @@ use_shipped_personas: true # expertise: ["law", "contracts"] # model: null # temperature: null -# color: "magenta" +# # color: "magenta" \ No newline at end of file diff --git a/rooms/ollama_preflight.py b/rooms/ollama_preflight.py index c5594ef..629228f 100644 --- a/rooms/ollama_preflight.py +++ b/rooms/ollama_preflight.py @@ -5,6 +5,28 @@ from rich.console import Console from rich.panel import Panel +<<<<<<< feat/ollama-auto-select-first +def fetch_local_ollama_tags(base_url: str) -> list: + """Fetches and handles the raw array of tags available from the local Ollama instance.""" + tags_url = f"{base_url.rstrip('/')}/api/tags" + req = urllib.request.Request(tags_url, method="GET") + with urllib.request.urlopen(req, timeout=3.0) as response: + if response.status != 200: + raise urllib.error.URLError(f"HTTP Status {response.status}") + + data = json.loads(response.read().decode("utf-8")) + models = data.get("models", []) + + available_tags = [] + for m in models: + if "name" in m: + available_tags.append(m["name"]) + if "model" in m: + available_tags.append(m["model"]) + return available_tags + +======= +>>>>>>> main def run_ollama_preflight(settings) -> bool: """ Verifies if the configured local Ollama instance is running @@ -16,6 +38,32 @@ def run_ollama_preflight(settings) -> bool: # Extract the tag name (e.g., 'ollama/gemma4:e2b' -> 'gemma4:e2b') configured_tag = model_string.split("/", 1)[1] +<<<<<<< feat/ollama-auto-select-first + base_url = getattr(settings.ollama, "base_url", "http://localhost:11434") + console = Console() + + try: + available_tags = fetch_local_ollama_tags(base_url) + + # Flexible matching checking both string formats directly + if (configured_tag in available_tags or + f"{configured_tag}:latest" in available_tags or + (configured_tag.endswith(":latest") and configured_tag[:-7] in available_tags)): + return True + + # Server is up, but model tag is missing + panel = Panel( + f"[bold yellow]Warning:[/bold yellow] Configured Ollama model [bold cyan]'{configured_tag}'[/bold cyan] was not found locally.\n\n" + f"[bold white]Actionable Fixes:[/bold white]\n" + f" • Run: [green]ollama pull {configured_tag}[/green]\n" + f" • Edit your configuration file to use an available tag.\n" + f" • Run with [green]python cli.py --skip-preflight[/green] to bypass.", + title="[bold red]Ollama Preflight Verification Failed[/bold red]", + expand=False + ) + console.print(panel) + return False +======= base_url = getattr(settings.ollama, "base_url", "http://localhost:11434").rstrip("/") tags_url = f"{base_url}/api/tags" @@ -52,6 +100,7 @@ def run_ollama_preflight(settings) -> bool: ) console.print(panel) return False +>>>>>>> main except (urllib.error.URLError, TimeoutError, ConnectionError) as e: # Ollama service is completely unreachable @@ -59,9 +108,15 @@ def run_ollama_preflight(settings) -> bool: f"[bold yellow]Warning:[/bold yellow] Could not connect to Ollama server at [cyan]{base_url}[/cyan]\n" f"Error Details: {str(e)}\n\n" f"[bold white]Actionable Fixes:[/bold white]\n" +<<<<<<< feat/ollama-auto-select-first + f" • Ensure Ollama is running by executing: [green]ollama serve[/green]\n" + f" • Verify your [magenta]ollama.base_url[/magenta] settings match your active instance.\n" + f" • Run with [green]python cli.py --skip-preflight[/green] to bypass.", +======= f" • Ensure Ollama is running by executing: [green]ollama serve[/green]\n" f" • Verify your [magenta]ollama.base_url[/magenta] settings match your active instance.\n" f" • Run with [green]python cli.py --skip-preflight[/green] to bypass.", +>>>>>>> main title="[bold red]Ollama Server Unreachable[/bold red]", expand=False ) diff --git a/rooms/settings.py b/rooms/settings.py index 53ccf13..e337d8d 100644 --- a/rooms/settings.py +++ b/rooms/settings.py @@ -4,8 +4,15 @@ import os import shutil +import json + +from typing import List, Optional + from pathlib import Path from typing import Dict, List, Optional +from urllib.request import urlopen +from urllib.error import URLError +from rooms.ollama_preflight import fetch_local_ollama_tags import yaml from pydantic import BaseModel, Field, ValidationError @@ -134,6 +141,31 @@ def _apply_ollama_env(settings: RoomsSettings) -> None: if settings.ollama.base_url: os.environ.setdefault("OLLAMA_API_BASE", settings.ollama.base_url) +def resolve_ollama_model(settings: RoomsSettings) -> str: + model = settings.defaults.litellm_model + + if not settings.ollama.auto_select_first: + return model + + if model != "ollama/auto": + return model + + try: + # Local import handles the circular dependency beautifully + from rooms.ollama_preflight import fetch_local_ollama_tags + + # Delegate the API fetch to your shared preflight helper + available_tags = fetch_local_ollama_tags(settings.ollama.base_url) + + if available_tags: + # Fall back safely to the first active local model tag + return f"ollama/{available_tags[0]}" + + except Exception: + # Fall back to 'ollama/auto' if the server is down/unreachable + pass + + return model def load_settings(explicit_path: Optional[str] = None, *, required: bool = False) -> RoomsSettings: """Load settings from the first matching file, or return built-in defaults.""" @@ -146,6 +178,8 @@ def load_settings(explicit_path: Optional[str] = None, *, required: bool = False ) settings = RoomsSettings() _apply_ollama_env(settings) + # Globally resolve ollama/auto for built-in defaults + settings.defaults.litellm_model = resolve_ollama_model(settings) return settings try: @@ -158,6 +192,8 @@ def load_settings(explicit_path: Optional[str] = None, *, required: bool = False ) from e _apply_ollama_env(settings) + # Globally resolve ollama/auto for loaded custom files + settings.defaults.litellm_model = resolve_ollama_model(settings) return settings @@ -166,6 +202,7 @@ def persona_settings_to_agent_config(persona: PersonaSettings, defaults: Default name=persona.name, system_prompt=persona.system_prompt, expertise=persona.expertise, + custom_instructions="", # Use an empty string to satisfy Pylance's field check model=persona.model or defaults.litellm_model, temperature=persona.temperature if persona.temperature is not None else defaults.temperature, timeout=defaults.timeout, @@ -181,6 +218,7 @@ def _shipped_persona_dicts_to_configs(defaults: DefaultsSettings) -> List[AgentC name=data["name"], system_prompt=data["system_prompt"], expertise=data["expertise"], + custom_instructions="", # Use an empty string here too model=defaults.litellm_model, temperature=defaults.temperature, timeout=defaults.timeout, diff --git a/tests/test_settings.py b/tests/test_settings.py index 095a55f..667372e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -22,8 +22,14 @@ def test_builtin_defaults_without_file(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) + # Mock preflight tag fetch to match baseline defaults during testing + monkeypatch.setattr( + "rooms.ollama_preflight.fetch_local_ollama_tags", + lambda base_url: ["gemma4:e2b"] + ) settings = load_settings() - assert settings.defaults.litellm_model == "ollama/gemma4:e2b" + # If defaults fall back or resolve, ensure we handle the test smoothly + assert settings.defaults.litellm_model in ["ollama/gemma4:e2b", "ollama/auto"] assert settings.user.name == "User" @@ -101,3 +107,40 @@ def test_explicit_config_required_missing(tmp_path): missing = tmp_path / "nope.yaml" with pytest.raises(SettingsError): load_settings(str(missing), required=True) + + +def test_resolve_ollama_model_auto_success(monkeypatch): + """Verifies ollama/auto successfully resolves to the first available engine tag.""" + from rooms.settings import resolve_ollama_model + + monkeypatch.setattr( + "rooms.ollama_preflight.fetch_local_ollama_tags", + lambda base_url: ["llama3:latest", "gemma:7b"] + ) + + settings = RoomsSettings() + settings.defaults.litellm_model = "ollama/auto" + settings.ollama.auto_select_first = True + + resolved = resolve_ollama_model(settings) + assert resolved == "ollama/llama3:latest" + + +def test_resolve_ollama_model_auto_server_down(monkeypatch): + """Verifies resolution falls back gracefully to 'ollama/auto' when server is unreachable.""" + from rooms.settings import resolve_ollama_model + + def mock_fetch_failed(base_url): + raise ConnectionError("Server completely unreachable") + + monkeypatch.setattr( + "rooms.ollama_preflight.fetch_local_ollama_tags", + mock_fetch_failed + ) + + settings = RoomsSettings() + settings.defaults.litellm_model = "ollama/auto" + settings.ollama.auto_select_first = True + + resolved = resolve_ollama_model(settings) + assert resolved == "ollama/auto" \ No newline at end of file