Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions src/praisonai-code/praisonai_code/llm/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,38 @@ def detect_local_endpoint():
return None


_OLLAMA_PREFIXES = ("ollama/", "ollama_chat/")


def _ollama_host_for_model(model: Optional[str]) -> 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. ``OLLAMA_API_BASE`` (what the
runtime honours) wins, 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 (
os.environ.get("OLLAMA_API_BASE")
or os.environ.get("OLLAMA_HOST")
or _DEFAULT_OLLAMA_HOST
)
Comment on lines +146 to +151

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Working remote agents are blocked

For run --agent, the gate receives only llm.model, but the agent later sends requests to its declared llm.base_url. This new check ignores that address and probes OLLAMA_API_BASE, OLLAMA_HOST, or localhost instead. If that different server is down, the CLI rejects the agent even when its actual Ollama server is running. Previously, setting OLLAMA_HOST let these configurations pass.

Pass the selected endpoint into the gate and probe the server the agent will actually use.



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.

Expand Down Expand Up @@ -258,6 +290,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:
Expand Down Expand Up @@ -346,6 +382,17 @@ def ensure_configured_or_onboard(
if is_configured(model):
return model

# An explicit Ollama model needs a running server, not a key: say so
# instead of sending the user to an API-key wizard.
ollama_host = _ollama_host_for_model(model)
if ollama_host is not None:
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)

# Keyless local-first: prefer a reachable local endpoint (e.g. Ollama) when
# no explicit model was requested, so the first run just works before auth.
local = detect_local_endpoint() if not model else None
Expand All @@ -360,11 +407,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)
Expand Down
12 changes: 9 additions & 3 deletions src/praisonai-code/praisonai_code/llm/local_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
124 changes: 124 additions & 0 deletions src/praisonai-code/tests/unit/test_ollama_explicit_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""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
Loading