AI: add a self-hosted Ollama backend, and let the picker choose the model
The bot could talk to OpenAI or Anthropic; this adds Ollama as a third provider so it can run against models hosted on our own box, and extends the switch command to pick WHICH model - not just which backend. Provider: Ollama exposes an OpenAI-compatible /v1 surface, so the client is just openai.AsyncOpenAI(base_url=OLLAMA_URL + "/v1"). That reuses the existing message format and the whole _map_openai_error mapping instead of forking a second error taxonomy. There is no API key - the endpoint IS the configuration, so the backend stays dormant (and refuses to be selected, with a message naming the variable) until CONJURER_OLLAMA_URL is set, the same way the Conan bridge behaves. Model selection: * list_provider_models() asks the SERVER for Ollama (/v1/models), so the picker shows what is actually pulled on the box rather than a hardcoded list. Hosted providers just report what they are wired to. * set_active_model() pins the config's latest_model and persists it; cheap_model is left alone so the MUSIC path keeps its cheaper backend. * $gadaj_teraz now takes "<config> [model]", and a new read-only $modele_ai lists what is available. Pinning an id Ollama does not have is rejected up front with the real list - otherwise the typo only surfaces later as a failed reply. Two fixes this exposed: * AI_CONFIGS now merges built-in defaults with the settings-file block instead of letting the file win outright. Every provider switch persists a "configs" block, so a file written by an older build would have permanently hidden ollama from the picker after an upgrade. * _persist_active_ai_config assigns "configs" instead of setdefault, so a pinned model actually survives a restart. * the hardcoded 120s response timeout is now CONJURER_AI_TIMEOUT_SECONDS - a self-hosted model on a modest GPU can legitimately need longer. Tests cover: ollama appears in the picker, select_model maps the legacy gpt-4o default instead of leaking it, model listing (server-queried, sorted, de-duplicated, failure -> AIError, unconfigured -> auth), pinning (latest only, blank/unknown rejected), and that provider_generate routes to the new path. Suite: 68 unit + 70 integration green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -136,3 +136,162 @@ def test_map_openai_error_categories():
|
||||
assert ai_functions._map_openai_error(_bare(openai.RateLimitError)).category == "rate_limit"
|
||||
assert ai_functions._map_openai_error(_bare(openai.APITimeoutError)).category == "timeout"
|
||||
assert ai_functions._map_openai_error(ValueError("x")).category == "api"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Ollama
|
||||
# The self-hosted backend is wired through Ollama's OpenAI-compatible surface,
|
||||
# so it reuses the message format and the error mapping above. What is new and
|
||||
# worth pinning: it must appear in the picker even on an upgraded settings file,
|
||||
# models come from the SERVER, and pinning one must stick.
|
||||
import asyncio # noqa: E402
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
def __init__(self, ident):
|
||||
self.id = ident
|
||||
|
||||
|
||||
class _FakeModels:
|
||||
def __init__(self, ids, raises=None):
|
||||
self._ids = ids
|
||||
self._raises = raises
|
||||
|
||||
async def list(self):
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
return types.SimpleNamespace(data=[_FakeModel(i) for i in self._ids])
|
||||
|
||||
|
||||
class _FakeOllamaClient:
|
||||
def __init__(self, ids=(), raises=None):
|
||||
self.models = _FakeModels(list(ids), raises)
|
||||
|
||||
|
||||
def test_ollama_config_is_offered_in_the_picker():
|
||||
assert "ollama" in ai_functions.list_ai_configs()
|
||||
cfg = ai_functions.AI_CONFIGS["ollama"]
|
||||
assert cfg["provider"] == "ollama"
|
||||
|
||||
|
||||
def test_select_model_uses_ollama_models_when_active(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
ai_functions.AI_CONFIGS,
|
||||
"ollama",
|
||||
{"provider": "ollama", "latest_model": "llama3.1:8b", "cheap_model": "qwen2.5:3b"},
|
||||
)
|
||||
_reset_active("ollama")
|
||||
try:
|
||||
# the legacy gpt-4o default must auto-map, not leak to Ollama
|
||||
assert ai_functions.select_model("GENERAL", "gpt-4o") == "llama3.1:8b"
|
||||
assert ai_functions.select_model("MUSIC", "gpt-4o") == "qwen2.5:3b"
|
||||
# an explicit id is still honoured verbatim
|
||||
assert ai_functions.select_model("GENERAL", "mistral:7b") == "mistral:7b"
|
||||
finally:
|
||||
_reset_active("gpt")
|
||||
|
||||
|
||||
def test_list_provider_models_queries_the_ollama_server(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ai_functions, "OLLAMACLIENT", _FakeOllamaClient(["b:2", "a:1", "a:1"])
|
||||
)
|
||||
models = asyncio.run(ai_functions.list_provider_models("ollama"))
|
||||
assert models == ["a:1", "b:2"] # sorted + de-duplicated
|
||||
|
||||
|
||||
def test_list_provider_models_for_hosted_provider_reports_configured_ids():
|
||||
models = asyncio.run(ai_functions.list_provider_models("gpt"))
|
||||
assert models == [
|
||||
ai_functions.AI_CONFIGS["gpt"]["latest_model"],
|
||||
ai_functions.AI_CONFIGS["gpt"]["cheap_model"],
|
||||
]
|
||||
|
||||
|
||||
def test_list_provider_models_wraps_server_failure(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ai_functions, "OLLAMACLIENT", _FakeOllamaClient(raises=ValueError("boom"))
|
||||
)
|
||||
try:
|
||||
asyncio.run(ai_functions.list_provider_models("ollama"))
|
||||
except ai_functions.AIError as exc:
|
||||
assert exc.category == "api"
|
||||
else:
|
||||
raise AssertionError("a server failure must surface as AIError")
|
||||
|
||||
|
||||
def test_list_provider_models_without_endpoint_is_an_auth_error(monkeypatch):
|
||||
monkeypatch.setattr(ai_functions, "OLLAMACLIENT", None)
|
||||
try:
|
||||
asyncio.run(ai_functions.list_provider_models("ollama"))
|
||||
except ai_functions.AIError as exc:
|
||||
assert exc.category == "auth"
|
||||
else:
|
||||
raise AssertionError("an unconfigured Ollama must surface as AIError")
|
||||
|
||||
|
||||
def test_set_active_model_pins_latest_and_keeps_cheap(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
ai_functions.AI_CONFIGS,
|
||||
"ollama",
|
||||
{"provider": "ollama", "latest_model": "old:1", "cheap_model": "cheap:1"},
|
||||
)
|
||||
written = {}
|
||||
monkeypatch.setattr(
|
||||
ai_functions, "_persist_active_ai_config", lambda name: written.update(name=name)
|
||||
)
|
||||
cfg = ai_functions.set_active_model("mistral:7b", "ollama")
|
||||
assert cfg["latest_model"] == "mistral:7b"
|
||||
assert cfg["cheap_model"] == "cheap:1" # MUSIC path untouched
|
||||
assert written # the choice was persisted
|
||||
|
||||
|
||||
def test_set_active_model_rejects_blank_and_unknown_config(monkeypatch):
|
||||
monkeypatch.setattr(ai_functions, "_persist_active_ai_config", lambda _n: None)
|
||||
for bad in ("", " "):
|
||||
try:
|
||||
ai_functions.set_active_model(bad, "gpt")
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("a blank model id must be rejected")
|
||||
try:
|
||||
ai_functions.set_active_model("x", "nie-ma-takiego")
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("an unknown config must be rejected")
|
||||
|
||||
|
||||
def test_switching_to_ollama_without_endpoint_explains_itself(monkeypatch):
|
||||
monkeypatch.setattr(ai_functions, "OLLAMACLIENT", None)
|
||||
try:
|
||||
ai_functions.set_active_ai_config("ollama")
|
||||
except RuntimeError as exc:
|
||||
assert "CONJURER_OLLAMA_URL" in str(exc)
|
||||
else:
|
||||
raise AssertionError("switching to an unconfigured Ollama must raise")
|
||||
finally:
|
||||
_reset_active("gpt")
|
||||
|
||||
|
||||
def test_provider_generate_routes_to_ollama(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
ai_functions.AI_CONFIGS,
|
||||
"ollama",
|
||||
{"provider": "ollama", "latest_model": "m:1", "cheap_model": "m:1"},
|
||||
)
|
||||
_reset_active("ollama")
|
||||
seen = {}
|
||||
|
||||
async def _fake_ollama_call(messages, model, cfg):
|
||||
seen.update(model=model, messages=messages)
|
||||
return "odpowiedź z domu"
|
||||
|
||||
monkeypatch.setattr(ai_functions, "_ollama_call", _fake_ollama_call)
|
||||
try:
|
||||
out = asyncio.run(
|
||||
ai_functions.provider_generate([{"role": "user", "content": "hej"}], "m:1")
|
||||
)
|
||||
finally:
|
||||
_reset_active("gpt")
|
||||
assert out == "odpowiedź z domu"
|
||||
assert seen["model"] == "m:1"
|
||||
|
||||
Reference in New Issue
Block a user