Skip to content
4 changes: 3 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
init_settings_file,
reset_settings_file,
find_settings_file,
resolve_ollama_model,
EXAMPLE_SETTINGS_FILENAME,
USER_SETTINGS_FILENAME,

)

console = Console()
Expand Down Expand Up @@ -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]"
Expand Down
4 changes: 3 additions & 1 deletion rooms.settings.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand All @@ -32,4 +34,4 @@ use_shipped_personas: true
# expertise: ["law", "contracts"]
# model: null
# temperature: null
# color: "magenta"
# # color: "magenta"
55 changes: 55 additions & 0 deletions rooms/ollama_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -52,16 +100,23 @@ 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
panel = Panel(
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
)
Expand Down
38 changes: 38 additions & 0 deletions rooms/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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:
Expand All @@ -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


Expand All @@ -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,
Expand All @@ -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,
Expand Down
45 changes: 44 additions & 1 deletion tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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"