ai: single-switch GPT/Claude backend for the chat cog

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>
This commit is contained in:
Michal Tuszowski
2026-07-09 21:34:59 +02:00
parent 4bde02e992
commit 3d9d47aa90
8 changed files with 572 additions and 58 deletions
+138
View File
@@ -0,0 +1,138 @@
"""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"