cfd19e2b34
Adversarial review of the previous commit found a real regression it
introduced, reproduced against the actual code rather than inferred.
Changing _persist_active_ai_config from setdefault("configs", ...) to a
direct assignment made every backend switch write the whole in-memory
AI_CONFIGS over the settings file. Because AI_CONFIGS is now the built-in
defaults merged UNDER the file, that meant:
* an operator's hand edits were destroyed - and hand editing is the only
way to change cheap_model / temperature / max_tokens, since
set_active_model writes latest_model and there is no command for the rest,
* a config deliberately deleted from the file was re-seeded from the
defaults and written back, permanently,
* pinning a model for one provider silently reverted another provider's
entry,
* CONJURER_OLLAMA_MODEL stopped having any effect once the env-derived
block had been persisted once.
The original motivation was still valid (plain setdefault would drop a
pinned model), so the fix is narrower rather than a revert: persist ONLY
the field this process actually changed. _persist_active_ai_config takes
model_for and writes back just that config's latest_model; everything else
in the on-disk block is left exactly as found. The constants.py merge stays
- it is what keeps a newly added provider visible after an upgrade - and is
now in-memory only, so it cannot reach the file.
Tests: the disk-write path had ZERO coverage, which is precisely how this
got in. Added four tests that drive the real _persist_active_ai_config
against a temp settings file: the pin lands while operator edits survive and
a deleted config is not resurrected; a plain switch leaves the configs block
byte-identical; a pin survives a re-read; a corrupt file does not raise.
Verified they have teeth - reintroducing the regression fails two of them.
Also hardened two weak tests the review caught: the pin test asserted on the
object set_active_model returns, which IS the mutated dict (so it passed
regardless), and the unconfigured-endpoint test monkeypatched OLLAMACLIENT
to None when it was already None, passing vacuously.
Suite: 72 unit + 70 integration green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
397 lines
14 KiB
Python
397 lines
14 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):
|
|
# Contrast, so the assertion cannot pass vacuously: with a client present the
|
|
# call succeeds, and ONLY setting it to None turns it into an auth error.
|
|
monkeypatch.setattr(ai_functions, "OLLAMACLIENT", _FakeOllamaClient(["a:1"]))
|
|
assert asyncio.run(ai_functions.list_provider_models("ollama")) == ["a:1"]
|
|
|
|
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, model_for=None: written.update(name=name, model_for=model_for),
|
|
|
|
)
|
|
ai_functions.set_active_model("mistral:7b", "ollama")
|
|
# Assert on the shared registry, not on the returned object - that object IS
|
|
# the mutated dict, so asserting on it would pass even if nothing was stored.
|
|
stored = ai_functions.AI_CONFIGS["ollama"]
|
|
assert stored["latest_model"] == "mistral:7b"
|
|
assert stored["cheap_model"] == "cheap:1" # MUSIC path untouched
|
|
assert written["name"] # the choice was persisted...
|
|
assert written["model_for"] == "ollama" # ...scoped to the config we changed
|
|
|
|
|
|
def test_set_active_model_rejects_blank_and_unknown_config(monkeypatch):
|
|
monkeypatch.setattr(
|
|
ai_functions, "_persist_active_ai_config", lambda _n, model_for=None: 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"
|
|
|
|
|
|
# ------------------------------------------------- persistence (real disk path)
|
|
# This path had NO coverage, which is exactly how a config-clobbering regression
|
|
# got in: persisting the whole in-memory AI_CONFIGS (built-in defaults merged
|
|
# under the file) overwrote operator hand-edits and resurrected deleted configs.
|
|
import json # noqa: E402
|
|
|
|
|
|
def _settings_file(tmp_path, configs, active="gpt"):
|
|
path = tmp_path / "system_gpt_settings.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
[
|
|
{"role": "system", "content": "sys"},
|
|
{"someuser": [1, "a", "b", "c", "asst_x"]},
|
|
{"active": active, "configs": configs},
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def test_persist_writes_the_pin_without_clobbering_operator_edits(tmp_path, monkeypatch):
|
|
# The file is authoritative for everything the bot does not itself change:
|
|
# a hand-tuned cheap_model, and a config deliberately deleted from it.
|
|
settings = _settings_file(
|
|
tmp_path,
|
|
{"gpt": {"provider": "openai", "latest_model": "gpt-4.1", "cheap_model": "hand-tuned"}},
|
|
)
|
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(settings))
|
|
monkeypatch.setitem(
|
|
ai_functions.AI_CONFIGS,
|
|
"ollama",
|
|
{"provider": "ollama", "latest_model": "mistral:7b", "cheap_model": "c:1"},
|
|
)
|
|
|
|
ai_functions._persist_active_ai_config("ollama", model_for="ollama")
|
|
|
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
configs = data[2]["configs"]
|
|
assert data[2]["active"] == "ollama"
|
|
assert configs["ollama"]["latest_model"] == "mistral:7b" # the pin landed
|
|
assert configs["gpt"]["latest_model"] == "gpt-4.1" # edit survived
|
|
assert configs["gpt"]["cheap_model"] == "hand-tuned" # edit survived
|
|
assert "claude" not in configs # a deleted config is NOT resurrected
|
|
assert data[0]["content"] == "sys" and "someuser" in data[1] # rest intact
|
|
|
|
|
|
def test_plain_switch_leaves_the_configs_block_untouched(tmp_path, monkeypatch):
|
|
original = {"gpt": {"provider": "openai", "latest_model": "gpt-4.1", "cheap_model": "hand-tuned"}}
|
|
settings = _settings_file(tmp_path, original, active="claude")
|
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(settings))
|
|
|
|
# Switching backend without pinning a model must only move "active".
|
|
ai_functions._persist_active_ai_config("gpt")
|
|
|
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
assert data[2]["active"] == "gpt"
|
|
assert data[2]["configs"] == original
|
|
|
|
|
|
def test_pinned_model_survives_a_restart(tmp_path, monkeypatch):
|
|
# The whole point of persisting: re-reading the file must yield the pin.
|
|
settings = _settings_file(tmp_path, {"ollama": {"provider": "ollama", "latest_model": "old:1"}})
|
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(settings))
|
|
monkeypatch.setitem(
|
|
ai_functions.AI_CONFIGS,
|
|
"ollama",
|
|
{"provider": "ollama", "latest_model": "new:2", "cheap_model": "c:1"},
|
|
)
|
|
|
|
ai_functions._persist_active_ai_config("ollama", model_for="ollama")
|
|
|
|
reread = json.loads(settings.read_text(encoding="utf-8"))[2]
|
|
assert reread["configs"]["ollama"]["latest_model"] == "new:2"
|
|
|
|
|
|
def test_persist_survives_an_unreadable_settings_file(tmp_path, monkeypatch):
|
|
# Best-effort by contract: a broken file must not raise into the command.
|
|
broken = tmp_path / "broken.json"
|
|
broken.write_text("{ not json", encoding="utf-8")
|
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(broken))
|
|
ai_functions._persist_active_ai_config("gpt", model_for="gpt") # must not raise
|