From 3d9d47aa90b954416e26cad39c575ba606765a7b Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Thu, 9 Jul 2026 21:34:59 +0200 Subject: [PATCH 1/2] 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 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 --- ai_commands.py | 51 +++++ ai_functions.py | 309 +++++++++++++++++++++----- constants.py | 83 +++++++ docker/env/bot.env.example | 9 +- requirements_bot.txt | 1 + system_gpt_settings.json | 24 ++ tests/unit/test_ai_provider_switch.py | 138 ++++++++++++ tests/unit/test_constants.py | 15 ++ 8 files changed, 572 insertions(+), 58 deletions(-) create mode 100644 tests/unit/test_ai_provider_switch.py diff --git a/ai_commands.py b/ai_commands.py index baa3bc1..59b238d 100644 --- a/ai_commands.py +++ b/ai_commands.py @@ -45,6 +45,14 @@ class Events(commands.Cog): async def cog_load(self): self.logger.info("Starting personal assistants") + # Personal assistants use the OpenAI Assistants API (threads/runs), which + # has no Anthropic equivalent - skip cleanly when OpenAI isn't wired up + # (e.g. a Claude-only deployment) instead of crashing the cog load. + if OPENAICLIENT is None: + self.logger.warning( + "OPENAICLIENT niedostępny - osobiści asystenci (OpenAI Assistants API) wyłączeni" + ) + return for superfryta_id, superfryta in SPECJALNE_ZIEMNIACZKI.items(): if superfryta[4] != "": @@ -101,6 +109,40 @@ class Events(commands.Cog): else: await ctx.reply("Nope. Nie wiesz jak użyć") + @commands.hybrid_command( + name="gadaj_teraz", + description="Przełącz backend AI (np. gpt / claude). Tylko Vykidailo.", + ) + async def gadaj_teraz(self, ctx, nazwa_konfigu: str): + async with ctx.channel.typing(): + is_admin = isinstance(ctx.author, discord.Member) and any( + role.name == "Vykidailo" for role in ctx.author.roles + ) + if not is_admin: + await discord_friendly_reply(ctx, "Tylko Vykidailo może przełączać AI.") + return + available = ai_functions.list_ai_configs() + if nazwa_konfigu not in available: + await discord_friendly_reply( + ctx, + f"Nie znam configu '{nazwa_konfigu}'. Dostępne: {', '.join(available)}", + ) + return + try: + cfg = ai_functions.set_active_ai_config(nazwa_konfigu) + except (KeyError, RuntimeError) as exc: + await discord_friendly_reply( + ctx, f"Nie mogę przełączyć na '{nazwa_konfigu}': {exc}" + ) + return + self.logger.info( + "Przełączono AI na config %s (%s)", nazwa_konfigu, cfg.get("provider") + ) + await discord_friendly_reply( + ctx, + f"Teraz gadam przez **{nazwa_konfigu}** — {cfg.get('provider')} / {cfg.get('latest_model')}.", + ) + @commands.hybrid_command( name="armia_hammera", description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj", @@ -301,6 +343,15 @@ class Events(commands.Cog): if "imaginuje sobie:" in message.content: async with channel.typing(): self.logger.info("Poczatek procedury obrazkowej") + # Image generation is DALL-E (OpenAI); there is no Anthropic + # equivalent, so it stays on OpenAI regardless of the chat + # backend. Degrade gracefully when OpenAI isn't configured. + if OPENAICLIENT is None: + await discord_friendly_reply( + message, + "*Kondziu rozkłada łapska* — malowanie obrazków jest teraz wyłączone (brak OpenAI).", + ) + return message_content_lower = message_content_lower.replace("imaginuje sobie: ", "") self.logger.debug("Wywolanie obrazka: %s", message_content_lower) try: diff --git a/ai_functions.py b/ai_functions.py index ef48fb7..6c2d6c8 100644 --- a/ai_functions.py +++ b/ai_functions.py @@ -8,8 +8,11 @@ import tiktoken import time from other_functions import discord_friendly_send from constants import ( + AI_CONFIGS, ASSISTANTS, + CLAUDECLIENT, CYCLIC_WORDS, + DEFAULT_AI_CONFIG, ENCODING, GPT_SETTINGS, MEMORY_FIVE_MUZYKA, @@ -23,19 +26,222 @@ from constants import ( LATEST_MODEL ) +try: + import anthropic +except ImportError: # pragma: no cover - optional at runtime + anthropic = None + # this do per user VECTOR_STORE_ID = -1 + + +# *=========================================== AI provider abstraction +# The AI cog talks to exactly one backend at a time, chosen by _ACTIVE_CONFIG. +# Legacy defaults ("gpt"/OpenAI) keep the historical behaviour byte-for-byte; +# selecting a "claude" config routes the same handle_response pipeline through +# the Anthropic Messages API instead. Backend-specific exceptions are funnelled +# into a single AIError so handle_response can keep its one set of in-character +# error replies regardless of provider. +_ACTIVE_CONFIG_NAME = DEFAULT_AI_CONFIG + +# Legacy default algorithm strings that mean "let the bot pick" rather than +# "force this exact model" - so a caller that still passes the old gpt-4o +# default auto-selects the active provider's model instead of 400-ing on Claude. +_AUTO_ALGOS = {"", "auto", "gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"} + + +class AIError(Exception): + """Provider-neutral wrapper so handle_response reacts to one exception type. + + ``category`` is one of: timeout, connection, bad_request, + response_validation, auth, permission, rate_limit, unprocessable, api. + ``original`` is the underlying SDK exception (interpolated into replies). + """ + + def __init__(self, category: str, original: Exception): + super().__init__(str(original)) + self.category = category + self.original = original + + +def _active_config() -> dict: + return ( + AI_CONFIGS.get(_ACTIVE_CONFIG_NAME) + or AI_CONFIGS.get("gpt") + or next(iter(AI_CONFIGS.values())) + ) + + +def list_ai_configs(): + """Selectable config names (templates prefixed with '_' are hidden).""" + return [name for name in AI_CONFIGS if not name.startswith("_")] + + +def get_active_ai_config() -> str: + return _ACTIVE_CONFIG_NAME + + +def set_active_ai_config(name: str) -> dict: + """Switch the active AI backend and persist the choice. Raises on error.""" + global _ACTIVE_CONFIG_NAME + if name not in AI_CONFIGS: + raise KeyError(name) + cfg = AI_CONFIGS[name] + provider = cfg.get("provider") + if provider == "anthropic" and CLAUDECLIENT is None: + raise RuntimeError("klient Anthropic nie jest skonfigurowany (brak ANTHROPIC_API_KEY)") + if provider == "openai" and OPENAICLIENT is None: + raise RuntimeError("klient OpenAI nie jest skonfigurowany (brak OPENAI_API_KEY)") + _ACTIVE_CONFIG_NAME = name + _persist_active_ai_config(name) + return cfg + + +def _persist_active_ai_config(name: str) -> None: + """Best-effort write of the active-config choice into system_gpt_settings.json. + + Keeps the historical two-element structure intact: updates index 2 if it + already exists, appends it when the file has exactly the original two + elements, and otherwise leaves the file untouched (the in-memory switch + still applies). + """ + logger = logging.getLogger("discord") + try: + with open(SYSTEM_GPT_SETTINGS, "r", encoding=ENCODING) as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Nie mogę odczytać %s do zapisu configu AI: %s", SYSTEM_GPT_SETTINGS, exc) + return + if not isinstance(data, list) or len(data) < 2: + logger.warning("Nietypowa struktura %s - pomijam zapis configu AI", SYSTEM_GPT_SETTINGS) + return + if len(data) > 2 and isinstance(data[2], dict): + data[2]["active"] = name + data[2].setdefault("configs", AI_CONFIGS) + else: + data = data[:2] + [{"active": name, "configs": AI_CONFIGS}] + try: + with open(SYSTEM_GPT_SETTINGS, "w", encoding=ENCODING) as handle: + json.dump(data, handle, indent=4, ensure_ascii=False) + except OSError as exc: + logger.warning("Nie mogę zapisać configu AI do %s: %s", SYSTEM_GPT_SETTINGS, exc) + + +def _map_openai_error(exc: Exception) -> AIError: + mapping = [ + (openai.APITimeoutError, "timeout"), + (openai.APIConnectionError, "connection"), + (openai.BadRequestError, "bad_request"), + (openai.APIResponseValidationError, "response_validation"), + (openai.AuthenticationError, "auth"), + (openai.PermissionDeniedError, "permission"), + (openai.RateLimitError, "rate_limit"), + (openai.UnprocessableEntityError, "unprocessable"), + (openai.APIError, "api"), + ] + for cls, category in mapping: + if isinstance(exc, cls): + return AIError(category, exc) + return AIError("api", exc) + + +def _map_anthropic_error(exc: Exception) -> AIError: + mapping = [ + ("APITimeoutError", "timeout"), + ("APIConnectionError", "connection"), + ("BadRequestError", "bad_request"), + ("APIResponseValidationError", "response_validation"), + ("AuthenticationError", "auth"), + ("PermissionDeniedError", "permission"), + ("RateLimitError", "rate_limit"), + ("UnprocessableEntityError", "unprocessable"), + ("APIError", "api"), + ] + for name, category in mapping: + cls = getattr(anthropic, name, None) + if cls and isinstance(exc, cls): + return AIError(category, exc) + return AIError("api", exc) + + +def _to_anthropic_messages(messages): + """Split OpenAI-style messages into (system_prompt, alternating convo). + + Claude takes the system prompt as a separate parameter (not a role in the + messages list) and requires the conversation to open with a user turn, so + system messages are concatenated out and any leading assistant turns are + dropped. + """ + system_parts = [] + convo = [] + for msg in messages: + role = msg.get("role") + content = msg.get("content", "") + if role == "system": + system_parts.append(content) + else: + convo.append( + {"role": "assistant" if role == "assistant" else "user", "content": content} + ) + while convo and convo[0]["role"] != "user": + convo.pop(0) + if not convo: + convo = [{"role": "user", "content": " "}] + return "\n\n".join(part for part in system_parts if part), convo + + +async def _anthropic_call(messages, model, cfg): + """Claude counterpart of openai_call. Returns a plain string.""" + if CLAUDECLIENT is None: + raise AIError("auth", RuntimeError("klient Anthropic nie jest skonfigurowany")) + system_prompt, convo = _to_anthropic_messages(messages) + kwargs = { + "model": model, + "max_tokens": int(cfg.get("max_tokens", 2048)), + "messages": convo, + } + if system_prompt: + kwargs["system"] = system_prompt + # NOTE: temperature is deliberately omitted - Opus 4.8 / Sonnet 5 reject + # sampling params with a 400. + try: + resp = await CLAUDECLIENT.messages.create(**kwargs) + except Exception as exc: # pylint: disable=broad-except + raise _map_anthropic_error(exc) + text = "".join( + block.text for block in resp.content if getattr(block, "type", None) == "text" + ) + return text.strip() + + +async def provider_generate(messages, model, temperature=0.2): + """Dispatch a chat completion to the active backend, normalising errors.""" + cfg = _active_config() + try: + if cfg.get("provider") == "anthropic": + return await _anthropic_call(messages, model, cfg) + return await openai_call(messages, model, temperature) + except AIError: + raise + except Exception as exc: # pylint: disable=broad-except + # Only the OpenAI path reaches here un-normalised (_anthropic_call + # already wraps its own errors). + raise _map_openai_error(exc) + + def select_model(req_type: str, algo: str) -> str: - # Jeżeli jawnie podano algorithm (i nie jest 'auto'/''): - if algo and str(algo).strip().lower() not in ("auto",): - # wyjątek: MUZYKA ma zawsze być tania — nadpisujemy TYLKO jeśli przyszedł domyślny 'gpt-4o' - if req_type == "MUSIC" and algo.strip() in (LATEST_MODEL,): - return CHEAP_MODEL - return algo - # Auto-dobór: + cfg = _active_config() + latest = cfg.get("latest_model", LATEST_MODEL) + cheap = cfg.get("cheap_model", CHEAP_MODEL) + algo_str = (algo or "").strip() + # An explicit, non-legacy model id is honoured verbatim; anything in + # _AUTO_ALGOS (incl. the old gpt-4o default) means "auto pick for the + # active provider", so flipping the switch actually changes the model. + if algo_str and algo_str.lower() not in _AUTO_ALGOS: + return algo_str if req_type == "MUSIC": - return CHEAP_MODEL - return LATEST_MODEL + return cheap + return latest async def openai_call(messages, model, temperature=0.2): @@ -256,57 +462,46 @@ async def handle_response( timeout_sec = 120 deadline = time.time() + timeout_sec response = await asyncio.wait_for( - openai_call(messages=history_msgs, model=model_to_use), + provider_generate(messages=history_msgs, model=model_to_use), timeout=max(0.1, deadline - time.time()), ) - except openai.APITimeoutError as e: - # Handle timeout error, e.g. retry or log - response = f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {e}" - except openai.APIConnectionError as e: - response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {e}" - except openai.BadRequestError as e: - # Handle invalid request error, e.g. validate parameters or log - if internal_retry: - resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło." - else: - resp, _ = await handle_response( - - f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'", - True, - True, - MESSAGE_TABLE, - username, - "RANDOM", - ) - response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}" - except openai.APIResponseValidationError as e: - # Handle invalid request error, e.g. validate parameters or log - if internal_retry: - resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło." - else: - resp, _ = await handle_response( - f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'", - True, - True, - MESSAGE_TABLE, - username, - "RANDOM", - ) - response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}" - except openai.AuthenticationError as e: - # Handle authentication error, e.g. check credentials or log - response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {e}" - except openai.PermissionDeniedError as e: - # Handle permission error, e.g. check scope or log - response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {e}" - except openai.RateLimitError as e: - response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {e}" - except openai.UnprocessableEntityError as e: - response = f"*Kondziu patrzy na terminal. Potem na to co każesz mu wysłać....* Ja wiem że jesteśmy w barze BDSM - ale nie da się włożyć TEGO w TO. *Za jego plecami na terminalu pojawia się:* {e}" - except openai.APIError as e: - # Handle API error, e.g. retry or log - response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}" + except AIError as e: + # One handler for both backends; e.category is provider-neutral and + # e.original is the underlying SDK exception (kept for the {..} tails). + err = e.original + if e.category == "timeout": + response = f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {err}" + elif e.category == "connection": + response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {err}" + elif e.category in ("bad_request", "response_validation"): + # Handle invalid request error, e.g. validate parameters or log + if internal_retry: + resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło." + else: + resp, _ = await handle_response( + f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {err} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'", + True, + True, + MESSAGE_TABLE, + username, + "RANDOM", + internal_retry=True, + ) + response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {err}" + elif e.category == "auth": + # Handle authentication error, e.g. check credentials or log + response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {err}" + elif e.category == "permission": + # Handle permission error, e.g. check scope or log + response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {err}" + elif e.category == "rate_limit": + response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {err}" + elif e.category == "unprocessable": + response = f"*Kondziu patrzy na terminal. Potem na to co każesz mu wysłać....* Ja wiem że jesteśmy w barze BDSM - ale nie da się włożyć TEGO w TO. *Za jego plecami na terminalu pojawia się:* {err}" + else: # "api" and anything unmapped + # Handle API error, e.g. retry or log + response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {err}" logger.info("Historia wysłana:") temp_assistant = {"role": "assistant", "content": response} diff --git a/constants.py b/constants.py index 685ff89..a70873a 100644 --- a/constants.py +++ b/constants.py @@ -38,6 +38,11 @@ try: except ImportError: # pragma: no cover - optional at runtime openai = None +try: + import anthropic +except ImportError: # pragma: no cover - optional at runtime + anthropic = None + try: import spotipy from spotipy.oauth2 import SpotifyClientCredentials @@ -336,6 +341,15 @@ if openai and OPENAI_API_KEY: else: OPENAICLIENT = None +# Claude / Anthropic client, wired analogously to OpenAI above so the AI cog can +# be pointed at either backend with a single config switch (see AI_CONFIGS and +# ai_functions.set_active_ai_config). netrc machine name 'anthropic' works too. +ANTHROPIC_API_KEY = _resolve_token("anthropic", "ANTHROPIC_API_KEY") +if anthropic and ANTHROPIC_API_KEY: + CLAUDECLIENT = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY) +else: + CLAUDECLIENT = None + TOKEN = _resolve_token("discord", "DISCORD_TOKEN") # Voice recognition (AssemblyAI). None = the voice cog reports and disables. @@ -388,6 +402,75 @@ GUILD_ID = 664789470779932693 LATEST_MODEL = "gpt-4o" # najnowszy/do rozmów (możesz zmienić w jednym miejscu) CHEAP_MODEL = "gpt-4o-mini" # najtańszy (fallback do 3.5 niżej) +# Claude counterparts of LATEST_MODEL / CHEAP_MODEL. Opus 4.8 is the strongest +# widely available model; Haiku 4.5 is the fast/cheap tier used for MUSIC. +CLAUDE_LATEST_MODEL = "claude-opus-4-8" +CLAUDE_CHEAP_MODEL = "claude-haiku-4-5" + + +# *=========================================== AI provider configs +# The bot's AI functionality (ai_functions.handle_response) can be pointed at a +# different backend by flipping a single "active" switch. Each named config +# collects the *differences* between providers (which backend, which models, +# generation params). These live in system_gpt_settings.json under an optional +# third list element (index 2) so future configs are simply added there: +# +# [ , , { "active": "gpt", +# "configs": { ... } } ] +# +# The block is optional and backward compatible: a settings file with only the +# original two elements falls back to the built-in defaults below (active +# "gpt"), so existing deployments behave exactly as before. The active config +# can be overridden at import time with CONJURER_AI_CONFIG and at runtime with +# the $gadaj_teraz command (which persists the choice back into index 2). +def _default_ai_configs(): + return { + "gpt": { + "provider": "openai", + "latest_model": LATEST_MODEL, + "cheap_model": CHEAP_MODEL, + "temperature": 0.2, + }, + "claude": { + "provider": "anthropic", + "latest_model": CLAUDE_LATEST_MODEL, + "cheap_model": CLAUDE_CHEAP_MODEL, + # Anthropic requires max_tokens; temperature is intentionally not + # sent for Claude (Opus 4.8 / Sonnet 5 reject sampling params). + "max_tokens": 2048, + }, + # Template for wiring further providers. Copy it, rename the key, point + # "provider" at a backend ai_functions.provider_generate implements, and + # fill in the model ids. Keys starting with "_" are treated as inert + # templates and are hidden from the $gadaj_teraz picker. + "_template": { + "provider": "openai", + "latest_model": "model-id-here", + "cheap_model": "cheaper-model-id-here", + "temperature": 0.2, + "max_tokens": 2048, + }, + } + + +_ai_block = ( + GPT_SETTINGS[2] + if isinstance(GPT_SETTINGS, list) and len(GPT_SETTINGS) > 2 and isinstance(GPT_SETTINGS[2], dict) + else {} +) +AI_CONFIGS = _ai_block.get("configs") or _default_ai_configs() +# Single switch: env var wins, then the settings-file "active" key, then "gpt". +DEFAULT_AI_CONFIG = ( + os.getenv("CONJURER_AI_CONFIG") + or _ai_block.get("active") + or "gpt" +) +if DEFAULT_AI_CONFIG not in AI_CONFIGS: + logger.warning( + "AI config '%s' not found - falling back to 'gpt'", DEFAULT_AI_CONFIG + ) + DEFAULT_AI_CONFIG = "gpt" if "gpt" in AI_CONFIGS else next(iter(AI_CONFIGS)) + # *=========================================== Conan Exiles bridge # Every value is optional; the conanjurer cog stays dormant unless configured. diff --git a/docker/env/bot.env.example b/docker/env/bot.env.example index b9cd93d..cacae3d 100644 --- a/docker/env/bot.env.example +++ b/docker/env/bot.env.example @@ -1,15 +1,22 @@ # Copy to docker/env/bot.env and fill in. Do NOT commit the real file. # --- Secrets ------------------------------------------------------------ -# Option A: mount a netrc (recommended — covers discord/openai/spotipy/youtube). +# Option A: mount a netrc (recommended — covers discord/openai/anthropic/spotipy/youtube). CONJURER_NETRC_FILE=/secrets/.netrc # Option B: pass tokens directly (these take precedence over netrc). # DISCORD_TOKEN= # OPENAI_API_KEY= +# ANTHROPIC_API_KEY= # Claude backend; netrc machine 'anthropic' works too # ASSEMBLYAI_API_KEY= # voice recognition; netrc machine 'assemblyai' works too # YOUTUBE_USERNAME= # YOUTUBE_PASSWORD= +# --- AI backend switch -------------------------------------------------- +# Which AI config from system_gpt_settings.json is active at startup +# (e.g. "gpt" or "claude"). Runtime switch: $gadaj_teraz . Unset = +# whatever the settings file's "active" key says, falling back to "gpt". +# CONJURER_AI_CONFIG=gpt + # --- Data --------------------------------------------------------------- # Single mounted volume; all writable state is rooted here. CONJURER_DATA_DIR=/data diff --git a/requirements_bot.txt b/requirements_bot.txt index 2dc3526..6aa5b54 100644 --- a/requirements_bot.txt +++ b/requirements_bot.txt @@ -4,6 +4,7 @@ yt_dlp spotify_dl spotipy openai +anthropic eyed3 numpy pdf2image diff --git a/system_gpt_settings.json b/system_gpt_settings.json index 604a25b..b6b0032 100644 --- a/system_gpt_settings.json +++ b/system_gpt_settings.json @@ -39,5 +39,29 @@ "Zrobisz absolutnie wszystko jako asystent, poniewa\u017c sama my\u015bl o tym \u017ce m\u00f3g\u0142by\u015b rozgniewa\u0107 operatora rozgniewa\u0107 Ci\u0119 przera\u017ca do poziomu histerii.", "asst_1PnD0eQMWo0RkZ3xUacC1uqJ" ] + }, + { + "active": "gpt", + "configs": { + "gpt": { + "provider": "openai", + "latest_model": "gpt-4o", + "cheap_model": "gpt-4o-mini", + "temperature": 0.2 + }, + "claude": { + "provider": "anthropic", + "latest_model": "claude-opus-4-8", + "cheap_model": "claude-haiku-4-5", + "max_tokens": 2048 + }, + "_template": { + "provider": "openai", + "latest_model": "model-id-here", + "cheap_model": "cheaper-model-id-here", + "temperature": 0.2, + "max_tokens": 2048 + } + } } ] \ No newline at end of file diff --git a/tests/unit/test_ai_provider_switch.py b/tests/unit/test_ai_provider_switch.py new file mode 100644 index 0000000..ff3d27f --- /dev/null +++ b/tests/unit/test_ai_provider_switch.py @@ -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" diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index 1cf9fe2..bf4c0d4 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -33,3 +33,18 @@ def test_conan_defaults_present(): # Feature toggles default to "off" so the bridge stays dormant. assert constants.CONAN_JOIN_CHANNEL_ID == 0 assert constants.CONAN_RCON_HOST == "" + + +def test_ai_configs_expose_gpt_and_claude_backends(): + cfgs = constants.AI_CONFIGS + assert cfgs["gpt"]["provider"] == "openai" + assert cfgs["claude"]["provider"] == "anthropic" + # Claude carries a max_tokens (Anthropic requires it) and omits temperature + # (Opus 4.8 / Sonnet 5 reject sampling params). + assert "max_tokens" in cfgs["claude"] + assert "temperature" not in cfgs["claude"] + + +def test_default_ai_config_is_a_known_config(): + # The single startup switch must always resolve to a real, selectable config. + assert constants.DEFAULT_AI_CONFIG in constants.AI_CONFIGS From 9f0191fba20b75f49c1ff54aa457a8598784815a Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Fri, 10 Jul 2026 23:39:21 +0200 Subject: [PATCH 2/2] docs: document ANTHROPIC_API_KEY + GPT/Claude backend switch Update the two deployment guides to cover the new env vars and the runtime switch: ANTHROPIC_API_KEY / netrc machine 'anthropic', CONJURER_AI_CONFIG (gpt|claude), the $gadaj_teraz command, and the note that DALL-E + personal assistants stay on OpenAI regardless of the switch. Co-Authored-By: Claude Opus 4.8 --- docs/deployment/DEPLOYMENT_GUIDE.md | 19 ++++++++++++++++++- docs/deployment/DOCKER_PROXMOX.md | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/deployment/DEPLOYMENT_GUIDE.md b/docs/deployment/DEPLOYMENT_GUIDE.md index 780e35c..9c6df3b 100644 --- a/docs/deployment/DEPLOYMENT_GUIDE.md +++ b/docs/deployment/DEPLOYMENT_GUIDE.md @@ -43,11 +43,28 @@ You can rebalance as follows: 2. Edit each `docker/env/*.env` to replace `HACKME!` with real values: - `DISCORD_TOKEN`, `OPENAI_API_KEY`, `CONJURER_API_KEY` (use the same value for all services). + - `ANTHROPIC_API_KEY` — only if you want the Claude backend (see “AI backend + switch” below). Safe to leave unset while running on GPT. - For musician/librarian, adjust mounts to the SMB paths you will mount on the Pis, e.g. `/mnt/conjurer/music`. - Set `CONJURER_CROSSREF_MAILTO` to a real email as required by Crossref. 3. If you rely on `.netrc`, copy it to `C:\Conjurer\secrets\.netrc` and set - `CONJURER_NETRC_FILE` accordingly. + `CONJURER_NETRC_FILE` accordingly. The bot reads netrc machines `discord`, + `openai`, `anthropic`, `assemblyai`, `spotipy`, `youtube`. + +### AI backend switch (GPT ↔ Claude) + +The AI chat features run on one backend at a time, selected by a single switch: + +- **Startup:** `CONJURER_AI_CONFIG=gpt` (default) or `=claude` in `bot.env`. Unset + falls back to the `"active"` key in `system_gpt_settings.json`, then `"gpt"`. +- **Runtime:** the `$gadaj_teraz ` Discord command (Vykidailo only) flips + the backend live and persists the choice. +- The named configs (which provider, which models) live in + `system_gpt_settings.json` — add more there. Claude needs `ANTHROPIC_API_KEY`; + GPT needs `OPENAI_API_KEY`. Image generation (`imaginuje sobie:`) and personal + assistants always use OpenAI regardless of the switch (Anthropic has no + equivalent), and degrade quietly if `OPENAI_API_KEY` is absent. ## 4. Install Docker on Raspberry Pis and Windows diff --git a/docs/deployment/DOCKER_PROXMOX.md b/docs/deployment/DOCKER_PROXMOX.md index 96a91f0..02549e8 100644 --- a/docs/deployment/DOCKER_PROXMOX.md +++ b/docs/deployment/DOCKER_PROXMOX.md @@ -85,6 +85,33 @@ sudo chmod 600 /srv/conjurer/secrets/.netrc (Alternatively skip netrc and set `DISCORD_TOKEN` / `OPENAI_API_KEY` in the env file — those take precedence.) +For the **Claude backend** add an `anthropic` machine to the same netrc (or set +`ANTHROPIC_API_KEY` in `bot.env`): + +``` +machine anthropic + password sk-ant-... +``` + +You only need this if you actually switch the bot to Claude — see 1c-bis. + +### 1c-bis. AI backend switch (GPT ↔ Claude) + +The bot's AI chat runs on one backend at a time, chosen by a single switch: + +- **At startup**, set `CONJURER_AI_CONFIG` in `bot.env` — `gpt` (default) or + `claude`. Leave it unset to use the `"active"` key in + `system_gpt_settings.json` (falls back to `gpt`). +- **At runtime**, `$gadaj_teraz ` (Vykidailo only) flips the backend live + and writes the choice back into `system_gpt_settings.json`. + +The provider configs (which backend, which models) are collected in +`system_gpt_settings.json` under the third list element — add further AIs there. +`claude` needs `ANTHROPIC_API_KEY`; `gpt` needs `OPENAI_API_KEY`. Image +generation (`imaginuje sobie:`) and personal assistants stay on OpenAI whatever +the switch says (Anthropic has no equivalent) and degrade quietly if OpenAI is +not configured, so a Claude-only box still boots. + ### 1d. Configure and launch ```bash