mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 13:34:40 +00:00
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:
+252
-57
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user