mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
3d9d47aa90
Wire the bot's AI chat pipeline (ai_functions.handle_response) to talk to either OpenAI or the Anthropic Messages API, chosen by one active-config switch. Behaviour on the default "gpt" config is unchanged. constants.py: * guarded `import anthropic` + CLAUDECLIENT (mirrors OPENAICLIENT), netrc machine 'anthropic' / ANTHROPIC_API_KEY; * CLAUDE_LATEST_MODEL / CLAUDE_CHEAP_MODEL (opus-4-8 / haiku-4-5); * AI_CONFIGS + DEFAULT_AI_CONFIG loaded from an optional 3rd element of system_gpt_settings.json (backward compatible - a 2-element file falls back to built-in defaults, active "gpt"). Single switch: CONJURER_AI_CONFIG env > settings "active" > "gpt". ai_functions.py: * provider_generate() dispatches to OpenAI (unchanged openai_call) or the new _anthropic_call() (splits system out, alternating messages, max_tokens, temperature omitted - Opus 4.8 rejects sampling params); * AIError normalises both SDKs' exceptions into one category set so handle_response keeps its single set of in-character error replies; * select_model() reads the active config; legacy "gpt-4o" default auto-maps to the active provider's model so the switch actually changes the backend; * set_active_ai_config()/list_ai_configs() with best-effort persistence back into system_gpt_settings.json index 2. ai_commands.py: * $gadaj_teraz <config> hybrid command (Vykidailo-gated) switches backend at runtime; * graceful guards when OPENAICLIENT is None: personal assistants (OpenAI Assistants API) and DALL-E image gen degrade instead of crashing, so a Claude-only deployment boots. system_gpt_settings.json: add the configs block (gpt/claude/_template) as the collection point for future backends. requirements_bot.txt: add anthropic. bot.env.example: ANTHROPIC_API_KEY + CONJURER_AI_CONFIG. Unit tests cover the message splitter, model selection, config listing, and error mapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.5 KiB
Python
139 lines
4.5 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"
|