diff --git a/src/praisonai-code/praisonai_code/cli/commands/run.py b/src/praisonai-code/praisonai_code/cli/commands/run.py index ead6f8134a..8c259e828a 100644 --- a/src/praisonai-code/praisonai_code/cli/commands/run.py +++ b/src/praisonai-code/praisonai_code/cli/commands/run.py @@ -1712,6 +1712,7 @@ def run_main( # execution path does not discover and execute the same definitions twice. preloaded_agent_config = None custom_agent_model = None + custom_agent_base_url = None if target or agent or command: # Any execution surface may need model/budget resolution import sys from praisonai_code.llm.credentials import ensure_configured_or_onboard @@ -1731,6 +1732,10 @@ def run_main( _preview_llm = (_preview_config or {}).get("llm") if isinstance(_preview_llm, dict): custom_agent_model = _preview_llm.get("model") + # Gate an Ollama agent on the server it will really call. + _base = _preview_llm.get("base_url") + if isinstance(_base, str) and _base.strip(): + custom_agent_base_url = _base.strip() else: custom_agent_model = _preview_llm if isinstance(custom_agent_model, str): @@ -1747,7 +1752,9 @@ def run_main( # endpoint, credentials, sampling, and retry options. credential_model = model or custom_agent_model resolved_model = ensure_configured_or_onboard( - model=credential_model, interactive=not _headless + model=credential_model, + interactive=not _headless, + base_url=None if model else custom_agent_base_url, ) if not (agent and model is None and custom_agent_model): model = resolved_model diff --git a/src/praisonai-code/praisonai_code/llm/credentials.py b/src/praisonai-code/praisonai_code/llm/credentials.py index 41359d1dea..e6d40d004c 100644 --- a/src/praisonai-code/praisonai_code/llm/credentials.py +++ b/src/praisonai-code/praisonai_code/llm/credentials.py @@ -123,6 +123,43 @@ def detect_local_endpoint(): return None +_OLLAMA_PREFIXES = ("ollama/", "ollama_chat/") + + +def _ollama_host_for_model( + model: Optional[str], base_url: Optional[str] = None +) -> Optional[str]: + """Return the Ollama host to probe for an ``ollama/`` model, else ``None``. + + Ollama needs no key, so an explicit Ollama model is gated on the server + being reachable rather than on a credential. The endpoint the caller will + actually use (e.g. an agent's ``llm.base_url``) wins, then + ``OLLAMA_API_BASE`` (what the runtime honours), then ``OLLAMA_HOST``, then + the default port. + """ + import os + + if not model or not model.lower().startswith(_OLLAMA_PREFIXES): + return None + from praisonai_code.llm.local_detect import _DEFAULT_OLLAMA_HOST + + return ( + base_url + or os.environ.get("OLLAMA_API_BASE") + or os.environ.get("OLLAMA_HOST") + or _DEFAULT_OLLAMA_HOST + ) + + +def _ollama_reachable(host: str) -> bool: + try: + from praisonai_code.llm.local_detect import ollama_reachable + + return ollama_reachable(host) + except Exception: + return False + + def _provider_key_vars_for_model(model: str) -> tuple[str, ...]: """Map a model id to the environment variable(s) for its provider. @@ -258,6 +295,10 @@ def is_configured(model: Optional[str] = None) -> bool: if model is None: model = default_model_for_available_provider() + ollama_host = _ollama_host_for_model(model) if explicit_model else None + if ollama_host is not None: + return _ollama_reachable(ollama_host) + required_vars = _provider_key_vars_for_model(model) if required_vars: @@ -303,6 +344,7 @@ def ensure_configured_or_onboard( *, model: Optional[str] = None, interactive: bool = True, + base_url: Optional[str] = None, ) -> Optional[str]: """Shared first-run credential gate for LLM-bound interactive commands. @@ -331,6 +373,9 @@ def ensure_configured_or_onboard( keyless local-first fallback (its own provider gate applies). interactive: Whether a wizard prompt may be shown. Callers pass ``False`` for headless (``-p``, piped, ``--output json``) paths. + base_url: The endpoint the caller will actually use (e.g. a custom + agent's ``llm.base_url``). For an explicit Ollama model this is the + server probed, instead of ``OLLAMA_API_BASE``/``OLLAMA_HOST``. Returns: The model id to proceed with. May be a detected local model id when the @@ -343,6 +388,21 @@ def ensure_configured_or_onboard( import typer inject_credentials_into_env() + + # An explicit Ollama model needs a running server, not a key: probe the + # endpoint the caller will really use and, if it is down, say so instead + # of sending the user to an API-key wizard. + ollama_host = _ollama_host_for_model(model, base_url) + if ollama_host is not None: + if _ollama_reachable(ollama_host): + return model + typer.echo( + f"Error: Ollama isn't reachable at {ollama_host} — start it with " + "`ollama serve` (or set OLLAMA_API_BASE).", + err=True, + ) + raise typer.Exit(1) + if is_configured(model): return model @@ -360,11 +420,16 @@ def ensure_configured_or_onboard( return local.model if not interactive: + # Only mention auto-detection when it actually ran and found nothing. + hint = f" for model {model}" if model else "" + tail = ( + "" if model else + "\n(no running local endpoint such as Ollama was found; one " + "would be used automatically)" + ) typer.echo( - "Error: No API key configured. Run: praisonai setup\n" - "or set environment variables like OPENAI_API_KEY\n" - "(a running local endpoint such as Ollama would be used " - "automatically)", + f"Error: No API key configured{hint}. Run: praisonai setup\n" + f"or set environment variables like OPENAI_API_KEY{tail}", err=True, ) raise typer.Exit(1) diff --git a/src/praisonai-code/praisonai_code/llm/local_detect.py b/src/praisonai-code/praisonai_code/llm/local_detect.py index 53d6f48143..feafdf56a0 100644 --- a/src/praisonai-code/praisonai_code/llm/local_detect.py +++ b/src/praisonai-code/praisonai_code/llm/local_detect.py @@ -19,9 +19,10 @@ from typing import Optional from urllib.parse import urlsplit -# Total time budget for a probe so the credential/first-run hot path is never -# stalled when nothing is listening. Kept small deliberately. -_PROBE_TIMEOUT_S = 0.15 +# Per-request probe timeout. Only paid on the keyless path; a closed port is +# refused instantly, while a running Ollama can take ~0.35 s on its first call, +# so a tighter budget made detection flaky (issue #6228). +_PROBE_TIMEOUT_S = 1.0 # How long a negative probe is remembered so repeated invocations in the same # process (e.g. bare TUI then run) don't re-pay the connection latency. @@ -187,6 +188,11 @@ def _probe_ollama_tags(host: str) -> Optional[str]: return None +def ollama_reachable(host: str) -> bool: + """Return ``True`` if an Ollama server answers ``/api/tags`` at ``host``.""" + return _get_json(_root_host(_with_scheme(host)) + "/api/tags") is not None + + def detect_local_model(*, use_cache: bool = True) -> Optional[LocalModel]: """Detect a reachable local OpenAI-compatible endpoint. diff --git a/src/praisonai-code/tests/unit/test_ollama_explicit_model.py b/src/praisonai-code/tests/unit/test_ollama_explicit_model.py new file mode 100644 index 0000000000..6de8da1c3e --- /dev/null +++ b/src/praisonai-code/tests/unit/test_ollama_explicit_model.py @@ -0,0 +1,144 @@ +"""Tests for issue #6228 — explicit ``ollama/`` models need no API key. + +An explicit Ollama model is gated on the server being reachable, not on +``OLLAMA_HOST`` being set; the probe tolerates a slow first response; and the +error text for an explicit model does not claim auto-detection would kick in. +""" + +import io +import json + +import pytest +import typer + +from praisonai_code.llm import credentials, local_detect + + +_ENV = ( + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", + "GROQ_API_KEY", "COHERE_API_KEY", "OPENROUTER_API_KEY", "OLLAMA_HOST", + "OLLAMA_API_BASE", "OPENAI_BASE_URL", "MODEL_NAME", "OPENAI_MODEL_NAME", + "PRAISONAI_LOCAL_ENDPOINTS", +) + + +@pytest.fixture +def clean_env(monkeypatch): + for var in _ENV: + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr(credentials, "inject_credentials_into_env", lambda: False) + local_detect.reset_cache() + yield monkeypatch + local_detect.reset_cache() + + +class _Resp(io.BytesIO): + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def _fake_urlopen(seen, delay=0.0): + def _urlopen(url, timeout): + seen.append((url, timeout)) + if delay > timeout: + raise TimeoutError("probe timed out") + return _Resp(json.dumps({"models": [{"name": "llama3.2:latest"}]}).encode()) + return _urlopen + + +def _refuse(url, timeout): + raise ConnectionRefusedError() + + +def test_explicit_ollama_configured_when_reachable(clean_env): + seen = [] + clean_env.setattr(local_detect.urllib.request, "urlopen", _fake_urlopen(seen)) + assert credentials.is_configured("ollama/llama3.2") is True + assert seen[0][0] == "http://127.0.0.1:11434/api/tags" + + +def test_explicit_ollama_honours_ollama_api_base(clean_env): + seen = [] + clean_env.setenv("OLLAMA_API_BASE", "http://gpu-box:11434") + clean_env.setattr(local_detect.urllib.request, "urlopen", _fake_urlopen(seen)) + assert credentials.is_configured("ollama_chat/llama3.2") is True + assert seen[0][0] == "http://gpu-box:11434/api/tags" + + +def test_explicit_ollama_not_configured_when_unreachable(clean_env): + clean_env.setattr(local_detect.urllib.request, "urlopen", _refuse) + assert credentials.is_configured("ollama/llama3.2") is False + + +def test_unreachable_ollama_error_is_actionable(clean_env, capsys): + clean_env.setattr(local_detect.urllib.request, "urlopen", _refuse) + with pytest.raises(typer.Exit): + credentials.ensure_configured_or_onboard( + model="ollama/llama3.2", interactive=False + ) + err = capsys.readouterr().err + assert "Ollama isn't reachable at http://127.0.0.1:11434" in err + assert "ollama serve" in err + assert "API key" not in err + + +def test_explicit_ollama_passes_gate_when_reachable(clean_env): + clean_env.setattr(local_detect.urllib.request, "urlopen", _fake_urlopen([])) + assert credentials.ensure_configured_or_onboard( + model="ollama/llama3.2", interactive=False + ) == "ollama/llama3.2" + + +def test_probe_tolerates_slow_first_response(clean_env): + """A ~0.4 s first /api/tags response must still be detected.""" + clean_env.setattr( + local_detect.urllib.request, "urlopen", _fake_urlopen([], delay=0.4) + ) + result = local_detect.detect_local_model(use_cache=False) + assert result is not None + assert result.model == "ollama/llama3.2:latest" + + +def test_explicit_cloud_model_error_omits_auto_detect_hint(clean_env, capsys): + clean_env.setattr(credentials, "detect_local_endpoint", lambda: None) + clean_env.setattr(credentials, "is_configured", lambda model=None: False) + with pytest.raises(typer.Exit): + credentials.ensure_configured_or_onboard( + model="gpt-4o-mini", interactive=False + ) + err = capsys.readouterr().err + assert "for model gpt-4o-mini" in err + assert "automatically" not in err + + +def test_keyless_no_model_error_mentions_auto_detect(clean_env, capsys): + clean_env.setattr(credentials, "detect_local_endpoint", lambda: None) + clean_env.setattr(credentials, "is_configured", lambda model=None: False) + with pytest.raises(typer.Exit): + credentials.ensure_configured_or_onboard(model=None, interactive=False) + assert "automatically" in capsys.readouterr().err + + +def test_gate_probes_agent_base_url_not_env_host(clean_env): + """A custom agent's ``llm.base_url`` is the server gated, not OLLAMA_HOST.""" + seen = [] + clean_env.setenv("OLLAMA_HOST", "http://down-box:11434") + + def _urlopen(url, timeout): + seen.append(url) + if "down-box" in url: + raise ConnectionRefusedError() + return _Resp(json.dumps({"models": []}).encode()) + + clean_env.setattr(local_detect.urllib.request, "urlopen", _urlopen) + assert credentials.ensure_configured_or_onboard( + model="ollama/llama3.2", + interactive=False, + base_url="http://gpu-box:11434/v1", + ) == "ollama/llama3.2" + assert seen == ["http://gpu-box:11434/api/tags"]