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:
@@ -45,6 +45,14 @@ class Events(commands.Cog):
|
|||||||
|
|
||||||
async def cog_load(self):
|
async def cog_load(self):
|
||||||
self.logger.info("Starting personal assistants")
|
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():
|
for superfryta_id, superfryta in SPECJALNE_ZIEMNIACZKI.items():
|
||||||
|
|
||||||
if superfryta[4] != "":
|
if superfryta[4] != "":
|
||||||
@@ -101,6 +109,40 @@ class Events(commands.Cog):
|
|||||||
else:
|
else:
|
||||||
await ctx.reply("Nope. Nie wiesz jak użyć")
|
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(
|
@commands.hybrid_command(
|
||||||
name="armia_hammera",
|
name="armia_hammera",
|
||||||
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
|
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:
|
if "imaginuje sobie:" in message.content:
|
||||||
async with channel.typing():
|
async with channel.typing():
|
||||||
self.logger.info("Poczatek procedury obrazkowej")
|
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: ", "")
|
message_content_lower = message_content_lower.replace("imaginuje sobie: ", "")
|
||||||
self.logger.debug("Wywolanie obrazka: %s", message_content_lower)
|
self.logger.debug("Wywolanie obrazka: %s", message_content_lower)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+238
-43
@@ -8,8 +8,11 @@ import tiktoken
|
|||||||
import time
|
import time
|
||||||
from other_functions import discord_friendly_send
|
from other_functions import discord_friendly_send
|
||||||
from constants import (
|
from constants import (
|
||||||
|
AI_CONFIGS,
|
||||||
ASSISTANTS,
|
ASSISTANTS,
|
||||||
|
CLAUDECLIENT,
|
||||||
CYCLIC_WORDS,
|
CYCLIC_WORDS,
|
||||||
|
DEFAULT_AI_CONFIG,
|
||||||
ENCODING,
|
ENCODING,
|
||||||
GPT_SETTINGS,
|
GPT_SETTINGS,
|
||||||
MEMORY_FIVE_MUZYKA,
|
MEMORY_FIVE_MUZYKA,
|
||||||
@@ -23,19 +26,222 @@ from constants import (
|
|||||||
LATEST_MODEL
|
LATEST_MODEL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import anthropic
|
||||||
|
except ImportError: # pragma: no cover - optional at runtime
|
||||||
|
anthropic = None
|
||||||
|
|
||||||
# this do per user
|
# this do per user
|
||||||
VECTOR_STORE_ID = -1
|
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:
|
def select_model(req_type: str, algo: str) -> str:
|
||||||
# Jeżeli jawnie podano algorithm (i nie jest 'auto'/''):
|
cfg = _active_config()
|
||||||
if algo and str(algo).strip().lower() not in ("auto",):
|
latest = cfg.get("latest_model", LATEST_MODEL)
|
||||||
# wyjątek: MUZYKA ma zawsze być tania — nadpisujemy TYLKO jeśli przyszedł domyślny 'gpt-4o'
|
cheap = cfg.get("cheap_model", CHEAP_MODEL)
|
||||||
if req_type == "MUSIC" and algo.strip() in (LATEST_MODEL,):
|
algo_str = (algo or "").strip()
|
||||||
return CHEAP_MODEL
|
# An explicit, non-legacy model id is honoured verbatim; anything in
|
||||||
return algo
|
# _AUTO_ALGOS (incl. the old gpt-4o default) means "auto pick for the
|
||||||
# Auto-dobór:
|
# 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":
|
if req_type == "MUSIC":
|
||||||
return CHEAP_MODEL
|
return cheap
|
||||||
return LATEST_MODEL
|
return latest
|
||||||
|
|
||||||
|
|
||||||
async def openai_call(messages, model, temperature=0.2):
|
async def openai_call(messages, model, temperature=0.2):
|
||||||
@@ -256,57 +462,46 @@ async def handle_response(
|
|||||||
timeout_sec = 120
|
timeout_sec = 120
|
||||||
deadline = time.time() + timeout_sec
|
deadline = time.time() + timeout_sec
|
||||||
response = await asyncio.wait_for(
|
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()),
|
timeout=max(0.1, deadline - time.time()),
|
||||||
)
|
)
|
||||||
|
|
||||||
except openai.APITimeoutError as e:
|
except AIError as e:
|
||||||
# Handle timeout error, e.g. retry or log
|
# One handler for both backends; e.category is provider-neutral and
|
||||||
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}"
|
# e.original is the underlying SDK exception (kept for the {..} tails).
|
||||||
except openai.APIConnectionError as e:
|
err = e.original
|
||||||
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}"
|
if e.category == "timeout":
|
||||||
except openai.BadRequestError as e:
|
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
|
# Handle invalid request error, e.g. validate parameters or log
|
||||||
if internal_retry:
|
if internal_retry:
|
||||||
resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
|
resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
|
||||||
else:
|
else:
|
||||||
resp, _ = await handle_response(
|
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'",
|
||||||
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,
|
||||||
True,
|
True,
|
||||||
MESSAGE_TABLE,
|
MESSAGE_TABLE,
|
||||||
username,
|
username,
|
||||||
"RANDOM",
|
"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ę: {e}"
|
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {err}"
|
||||||
except openai.APIResponseValidationError as e:
|
elif e.category == "auth":
|
||||||
# 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
|
# 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}"
|
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}"
|
||||||
except openai.PermissionDeniedError as e:
|
elif e.category == "permission":
|
||||||
# Handle permission error, e.g. check scope or log
|
# 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}"
|
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}"
|
||||||
except openai.RateLimitError as e:
|
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ć. {e}"
|
response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {err}"
|
||||||
except openai.UnprocessableEntityError as e:
|
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ę:* {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ę:* {err}"
|
||||||
except openai.APIError as e:
|
else: # "api" and anything unmapped
|
||||||
# Handle API error, e.g. retry or log
|
# 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}"
|
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:")
|
logger.info("Historia wysłana:")
|
||||||
temp_assistant = {"role": "assistant", "content": response}
|
temp_assistant = {"role": "assistant", "content": response}
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ try:
|
|||||||
except ImportError: # pragma: no cover - optional at runtime
|
except ImportError: # pragma: no cover - optional at runtime
|
||||||
openai = None
|
openai = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import anthropic
|
||||||
|
except ImportError: # pragma: no cover - optional at runtime
|
||||||
|
anthropic = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import spotipy
|
import spotipy
|
||||||
from spotipy.oauth2 import SpotifyClientCredentials
|
from spotipy.oauth2 import SpotifyClientCredentials
|
||||||
@@ -336,6 +341,15 @@ if openai and OPENAI_API_KEY:
|
|||||||
else:
|
else:
|
||||||
OPENAICLIENT = None
|
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")
|
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
|
||||||
|
|
||||||
# Voice recognition (AssemblyAI). None = the voice cog reports and disables.
|
# 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)
|
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)
|
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:
|
||||||
|
#
|
||||||
|
# [ <system message>, <personal assistants>, { "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
|
# *=========================================== Conan Exiles bridge
|
||||||
# Every value is optional; the conanjurer cog stays dormant unless configured.
|
# Every value is optional; the conanjurer cog stays dormant unless configured.
|
||||||
|
|||||||
Vendored
+8
-1
@@ -1,15 +1,22 @@
|
|||||||
# Copy to docker/env/bot.env and fill in. Do NOT commit the real file.
|
# Copy to docker/env/bot.env and fill in. Do NOT commit the real file.
|
||||||
|
|
||||||
# --- Secrets ------------------------------------------------------------
|
# --- 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
|
CONJURER_NETRC_FILE=/secrets/.netrc
|
||||||
# Option B: pass tokens directly (these take precedence over netrc).
|
# Option B: pass tokens directly (these take precedence over netrc).
|
||||||
# DISCORD_TOKEN=
|
# DISCORD_TOKEN=
|
||||||
# OPENAI_API_KEY=
|
# OPENAI_API_KEY=
|
||||||
|
# ANTHROPIC_API_KEY= # Claude backend; netrc machine 'anthropic' works too
|
||||||
# ASSEMBLYAI_API_KEY= # voice recognition; netrc machine 'assemblyai' works too
|
# ASSEMBLYAI_API_KEY= # voice recognition; netrc machine 'assemblyai' works too
|
||||||
# YOUTUBE_USERNAME=
|
# YOUTUBE_USERNAME=
|
||||||
# YOUTUBE_PASSWORD=
|
# 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 <config>. Unset =
|
||||||
|
# whatever the settings file's "active" key says, falling back to "gpt".
|
||||||
|
# CONJURER_AI_CONFIG=gpt
|
||||||
|
|
||||||
# --- Data ---------------------------------------------------------------
|
# --- Data ---------------------------------------------------------------
|
||||||
# Single mounted volume; all writable state is rooted here.
|
# Single mounted volume; all writable state is rooted here.
|
||||||
CONJURER_DATA_DIR=/data
|
CONJURER_DATA_DIR=/data
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ yt_dlp
|
|||||||
spotify_dl
|
spotify_dl
|
||||||
spotipy
|
spotipy
|
||||||
openai
|
openai
|
||||||
|
anthropic
|
||||||
eyed3
|
eyed3
|
||||||
numpy
|
numpy
|
||||||
pdf2image
|
pdf2image
|
||||||
|
|||||||
@@ -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.",
|
"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"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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"
|
||||||
@@ -33,3 +33,18 @@ def test_conan_defaults_present():
|
|||||||
# Feature toggles default to "off" so the bridge stays dormant.
|
# Feature toggles default to "off" so the bridge stays dormant.
|
||||||
assert constants.CONAN_JOIN_CHANNEL_ID == 0
|
assert constants.CONAN_JOIN_CHANNEL_ID == 0
|
||||||
assert constants.CONAN_RCON_HOST == ""
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user