Files
conjurer/tests/unit/test_ai_provider_switch.py
T
gitea 6a6b821a0d 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>
2026-08-24 15:41:28 +00:00

298 lines
9.8 KiB
Python

"""Unit tests for the GPT/Claude provider switch.
The unit CI job installs only pytest, so the heavy runtime deps that
``ai_functions`` imports unguarded (``openai``, ``tiktoken``, ``other_functions``
-> ``discord``) are stubbed *only when genuinely absent*. Locally, where the
real packages exist, the stubs are skipped and the real modules are used.
"""
import sys
import types
def _stub_if_missing(name: str, build):
if name in sys.modules:
return
try: # real package present (local dev / bot image) -> use it
__import__(name)
except ImportError:
sys.modules[name] = build()
def _build_tiktoken():
mod = types.ModuleType("tiktoken")
class _Enc:
def encode(self, text):
return list(text)
mod.encoding_for_model = lambda _model: _Enc()
return mod
def _build_other_functions():
mod = types.ModuleType("other_functions")
async def _noop(*_a, **_k):
return None
mod.discord_friendly_send = _noop
mod.discord_friendly_reply = _noop
return mod
def _build_openai():
mod = types.ModuleType("openai")
for cls_name in (
"APITimeoutError",
"APIConnectionError",
"BadRequestError",
"APIResponseValidationError",
"AuthenticationError",
"PermissionDeniedError",
"RateLimitError",
"UnprocessableEntityError",
"APIError",
"OpenAIError",
):
setattr(mod, cls_name, type(cls_name, (Exception,), {}))
return mod
_stub_if_missing("tiktoken", _build_tiktoken)
_stub_if_missing("other_functions", _build_other_functions)
_stub_if_missing("openai", _build_openai)
import ai_functions # noqa: E402 (import after stubbing)
import openai # noqa: E402 (real or stub, same object ai_functions uses)
def _reset_active(name="gpt"):
ai_functions._ACTIVE_CONFIG_NAME = name
def test_split_extracts_system_and_starts_with_user():
system, convo = ai_functions._to_anthropic_messages(
[
{"role": "system", "content": "SYS1"},
{"role": "system", "content": "SYS2"},
{"role": "assistant", "content": "leading-assistant-dropped"},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "hi"},
]
)
assert system == "SYS1\n\nSYS2"
assert convo[0] == {"role": "user", "content": "u1"}
assert convo == [
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "hi"},
]
def test_split_synthesises_user_turn_when_only_system():
_system, convo = ai_functions._to_anthropic_messages(
[{"role": "system", "content": "only"}]
)
assert convo == [{"role": "user", "content": " "}]
def test_select_model_gpt_active():
_reset_active("gpt")
# legacy default auto-selects; MUSIC is cheap; explicit ids are honoured
assert ai_functions.select_model("GENERAL", "gpt-4o") == "gpt-4o"
assert ai_functions.select_model("MUSIC", "gpt-4o") == "gpt-4o-mini"
assert ai_functions.select_model("MUSIC", "auto") == "gpt-4o-mini"
assert ai_functions.select_model("GENERAL", "o1-preview") == "o1-preview"
def test_select_model_claude_active_maps_legacy_default():
_reset_active("claude")
try:
# the old gpt-4o default must not leak to Claude - it auto-maps
assert ai_functions.select_model("GENERAL", "gpt-4o") == "claude-opus-4-8"
assert ai_functions.select_model("MUSIC", "gpt-4o") == "claude-haiku-4-5"
# a real, deliberate model id is still honoured verbatim
assert ai_functions.select_model("GENERAL", "claude-sonnet-5") == "claude-sonnet-5"
finally:
_reset_active("gpt")
def test_list_ai_configs_hides_templates():
names = ai_functions.list_ai_configs()
assert "_template" not in names
assert {"gpt", "claude"}.issubset(set(names))
def _bare(cls):
# Build an instance without invoking __init__ - the real openai SDK
# exceptions require response/body kwargs, the CI stubs don't. isinstance
# (all _map_openai_error cares about) works on __new__-created instances.
return cls.__new__(cls)
def test_map_openai_error_categories():
assert ai_functions._map_openai_error(_bare(openai.AuthenticationError)).category == "auth"
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"