diff --git a/src/timmy/tools_intro/__init__.py b/src/timmy/tools_intro/__init__.py index 21ede93..c2fb742 100644 --- a/src/timmy/tools_intro/__init__.py +++ b/src/timmy/tools_intro/__init__.py @@ -55,26 +55,45 @@ def get_system_info() -> dict[str, Any]: def _get_ollama_model() -> str: - """Query Ollama API to get the current model.""" + """Query Ollama API to get the actual running model. + + Strategy: + 1. /api/ps — models currently loaded in memory (most accurate) + 2. /api/tags — all installed models (fallback) + Both use exact name match to avoid prefix collisions + (e.g. 'qwen3:30b' vs 'qwen3.5:latest'). + """ from config import settings + configured = settings.ollama_model + try: - # First try to get tags to see available models + # First: check actually loaded models via /api/ps + response = httpx.get(f"{settings.ollama_url}/api/ps", timeout=5) + if response.status_code == 200: + running = response.json().get("models", []) + for model in running: + name = model.get("name", "") + if name == configured or name == f"{configured}:latest": + return name + # Configured model not loaded — return first running model + # so Timmy reports what's *actually* serving his requests + if running: + return running[0].get("name", configured) + + # Second: check installed models via /api/tags (exact match) response = httpx.get(f"{settings.ollama_url}/api/tags", timeout=5) if response.status_code == 200: - models = response.json().get("models", []) - # Check if configured model is available - for model in models: - if model.get("name", "").startswith(settings.ollama_model.split(":")[0]): - return settings.ollama_model - - # Fallback: return configured model - return settings.ollama_model + installed = response.json().get("models", []) + for model in installed: + name = model.get("name", "") + if name == configured or name == f"{configured}:latest": + return configured except Exception: pass # Fallback to configured model - return settings.ollama_model + return configured def check_ollama_health() -> dict[str, Any]: diff --git a/tests/timmy/test_introspection.py b/tests/timmy/test_introspection.py index da8c137..6b2d733 100644 --- a/tests/timmy/test_introspection.py +++ b/tests/timmy/test_introspection.py @@ -1,5 +1,9 @@ """Tests for system introspection tools.""" +from unittest.mock import MagicMock, patch + +import httpx + def test_get_system_info_returns_dict(): """System info should return a dictionary.""" @@ -15,15 +19,17 @@ def test_get_system_info_returns_dict(): def test_get_system_info_contains_model(): - """System info should include model name.""" - from config import settings + """System info should include a model name (may differ from config if + the actual running model is different — see issue #77).""" from timmy.tools_intro import get_system_info info = get_system_info() assert "model" in info - # Model should come from settings - assert info["model"] == settings.ollama_model + # Model should be a non-empty string — exact value depends on what + # Ollama has loaded (verified by TestGetOllamaModelExactMatch tests) + assert isinstance(info["model"], str) + assert len(info["model"]) > 0 def test_get_system_info_contains_repo_root(): @@ -59,3 +65,96 @@ def test_get_memory_status_returns_dict(): assert isinstance(status, dict) assert "tier1_hot_memory" in status assert "tier2_vault" in status + + +# --- _get_ollama_model exact-match tests (issue #77) --- + + +def _mock_response(json_data, status_code=200): + """Create a mock httpx response.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data + return resp + + +class TestGetOllamaModelExactMatch: + """Ensure _get_ollama_model uses exact match, not prefix match.""" + + @patch("timmy.tools_intro.httpx.get") + def test_exact_match_from_ps(self, mock_get): + """Should return exact model from /api/ps.""" + from timmy.tools_intro import _get_ollama_model + + ps_resp = _mock_response({"models": [{"name": "qwen3:30b"}]}) + mock_get.return_value = ps_resp + + with patch("config.settings") as mock_settings: + mock_settings.ollama_model = "qwen3:30b" + mock_settings.ollama_url = "http://localhost:11434" + result = _get_ollama_model() + + assert result == "qwen3:30b" + + @patch("timmy.tools_intro.httpx.get") + def test_prefix_collision_returns_correct_model(self, mock_get): + """qwen3:30b configured — must NOT match qwen3.5:latest (prefix bug).""" + from timmy.tools_intro import _get_ollama_model + + # /api/ps has both models loaded; configured is qwen3:30b + ps_resp = _mock_response({"models": [{"name": "qwen3.5:latest"}, {"name": "qwen3:30b"}]}) + mock_get.return_value = ps_resp + + with patch("config.settings") as mock_settings: + mock_settings.ollama_model = "qwen3:30b" + mock_settings.ollama_url = "http://localhost:11434" + result = _get_ollama_model() + + assert result == "qwen3:30b", f"Got '{result}' — prefix collision bug!" + + @patch("timmy.tools_intro.httpx.get") + def test_configured_model_not_running_returns_actual(self, mock_get): + """If configured model isn't loaded, report what IS running.""" + from timmy.tools_intro import _get_ollama_model + + ps_resp = _mock_response({"models": [{"name": "qwen3.5:latest"}]}) + mock_get.return_value = ps_resp + + with patch("config.settings") as mock_settings: + mock_settings.ollama_model = "qwen3:30b" + mock_settings.ollama_url = "http://localhost:11434" + result = _get_ollama_model() + + # Should report actual running model, not configured one + assert result == "qwen3.5:latest" + + @patch("timmy.tools_intro.httpx.get") + def test_latest_suffix_match(self, mock_get): + """'qwen3:30b' config should match 'qwen3:30b:latest' from API.""" + from timmy.tools_intro import _get_ollama_model + + ps_resp = _mock_response({"models": []}) + tags_resp = _mock_response({"models": [{"name": "qwen3:30b:latest"}]}) + mock_get.side_effect = [ps_resp, tags_resp] + + with patch("config.settings") as mock_settings: + mock_settings.ollama_model = "qwen3:30b" + mock_settings.ollama_url = "http://localhost:11434" + result = _get_ollama_model() + + # Falls back to configured since no exact match + assert result == "qwen3:30b" + + @patch("timmy.tools_intro.httpx.get") + def test_ollama_down_returns_configured(self, mock_get): + """If Ollama is unreachable, return configured model.""" + from timmy.tools_intro import _get_ollama_model + + mock_get.side_effect = httpx.ConnectError("connection refused") + + with patch("config.settings") as mock_settings: + mock_settings.ollama_model = "qwen3:30b" + mock_settings.ollama_url = "http://localhost:11434" + result = _get_ollama_model() + + assert result == "qwen3:30b"